# 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
"""Typed-preset selection via Hydra-style CLI tokens.
Recognizes three ``key=value`` tokens (no leading dashes) on ``sys.argv``:
* ``physics=NAME`` -- typed selector for ``PhysicsCfg`` variants.
* ``renderer=NAME`` -- typed selector for ``RendererCfg`` variants.
* ``presets=NAME[,NAME,...]`` -- broadcast applied to every matching ``PresetCfg``.
:func:`setup_preset_cli` registers preset-selection help and, for RL callers,
agent discovery. It then runs ``parse_known_args``, returning the verbatim
remainder. The preset tokens above are passed through unchanged; hydra's
:func:`~isaaclab_tasks.utils.hydra.register_task` parses them directly (applying
the names as presets and enforcing that ``physics=``/``renderer=`` resolve
against a config of that type). Callers simply assign the remainder to
``sys.argv``; no rewriting step is needed.
No argparse arguments are registered for the typed selectors -- their
discoverability lives in the ``argument_group`` description, so the parsed
Namespace gains no preset attributes and cannot shadow
:class:`~isaaclab.app.AppLauncher` SimulationApp config keys (``renderer``
notably).
Typical script setup::
parser = argparse.ArgumentParser(...)
# ... script-specific args ...
add_launcher_args(parser)
args_cli, remaining = setup_preset_cli(parser)
sys.argv = [sys.argv[0]] + remaining
Scripts that intersect the remainder with external-callback output (e.g.
``rsl_rl`` scripts' ``--external_callback`` hook) do the intersection on the
remainder before assigning ``sys.argv`` -- both sides share the same token
vocabulary::
args_cli, remaining = setup_preset_cli(parser)
if args_cli.external_callback:
remaining = list_intersection(remaining, external_callback_function())
sys.argv = [sys.argv[0]] + remaining
``setup_preset_cli`` does NOT add AppLauncher flags itself -- callers add them
explicitly via :func:`isaaclab.app.add_launcher_args` before calling.
"""
from __future__ import annotations
import argparse
import sys
from .preset_target import PresetTarget
# ============================================================================
# Public entry point
# ============================================================================
[docs]
def setup_preset_cli(
parser: argparse.ArgumentParser,
argv: list[str] | None = None,
*,
agent_library: str | None = None,
) -> tuple[argparse.Namespace, list[str]]:
"""Register the preset-selection help description and parse argv.
Must be called *after* AppLauncher flags and script-specific arguments are
registered on ``parser`` -- otherwise those unknown tokens land in
``parse_known_args``'s remainder.
The returned remainder contains the user-typed ``physics=`` / ``renderer=``
/ ``presets=`` tokens verbatim, alongside any Hydra path overrides and any
unknown argparse flags, ready to assign to ``sys.argv`` for hydra to parse.
Does not mutate ``sys.argv``; the caller assigns
``sys.argv = [sys.argv[0]] + remaining`` when ready, so any argv-aware logic
that re-reads ``sys.argv`` (e.g. an external callback) runs against the
user's original command line first.
Args:
parser: Caller's argument parser. An ``argument_group`` is attached
for help-time variant discovery. No preset selector arguments are
added, so the Namespace gains no preset attributes.
argv: Optional argument list to parse. When ``None`` (default),
``parse_known_args`` reads from ``sys.argv``. Provided primarily
for in-process test paths that drive the parser with a synthetic
argv. Help-time variant enumeration always reads ``sys.argv`` --
the user's interactive command line is the only argv that
triggers ``--help`` rendering.
agent_library: Optional RL-library prefix. When provided, task-specific
help lists registered ``--agent`` values and declared preset
compatibility.
Returns:
``(args, remaining)`` where ``remaining`` is the verbatim output of
``parser.parse_known_args(argv)``, ready to hand to Hydra via
``sys.argv``.
Raises:
SystemExit: If ``argv`` requests help, after printing it.
"""
# --help short-circuits parsing, so help text that depends on --task has to
# find it before argparse runs. Gate the env_cfg load on --help to keep
# normal training runs cheap.
argv_helper = _ArgvHelper(sys.argv)
actual_variants = None
if argv_helper.task_name and argv_helper.help_requested:
actual_variants = _enumerate_variants(argv_helper.task_name)
# Argparse's default HelpFormatter reflows description text into one wrapped
# paragraph, which would collapse the per-variant bullets we emit. Use a
# formatter that wraps each blank-line-separated paragraph independently
# while preserving explicit newlines. Respect a caller-set custom formatter.
if parser.formatter_class is argparse.HelpFormatter:
parser.formatter_class = _PresetHelpFormatter
# Help-only group: no add_argument() calls means no preset attributes on
# the Namespace, so AppLauncher can't accidentally forward one (notably
# ``renderer``) into SimulationApp config.
parser.add_argument_group("preset selection", description=_DescriptionBuilder.build(actual_variants))
if agent_library:
parser.add_argument_group(
"agent selection",
description=_AgentDescriptionBuilder.build(agent_library, argv_helper.task_name),
)
args_to_parse = sys.argv[1:] if argv is None else argv
if "-h" in args_to_parse or "--help" in args_to_parse:
parser.print_help()
raise SystemExit(0)
args, remaining = parser.parse_known_args(args_to_parse)
task_name = getattr(args, "task", None) or argv_helper.task_name
if agent_library and getattr(args, "agent", None) is None and task_name:
_auto_select_agent(args, task_name, agent_library, args_to_parse)
return args, remaining
# ============================================================================
# Public preset enumeration (for tooling, e.g. list_envs)
# ============================================================================
def enumerate_task_presets(task_name: str) -> dict[PresetTarget, list[str]] | None:
"""Return the available preset names for *task_name*, bucketed by selector type.
Loads the env config registered under *task_name* and walks its preset tree
using the same logic that the CLI help-text renderer uses, so the returned
view matches what ``--task=<name> --help`` shows at the command line.
This function is safe to call after :class:`~isaaclab.app.AppLauncher` has
booted (i.e. inside a running Isaac Sim session).
Args:
task_name: Gymnasium task ID (e.g. ``"Isaac-Cartpole"``).
Returns:
A mapping ``{PresetTarget: sorted list of preset names}`` on success.
Returns ``None`` if the env config cannot be loaded (import error,
missing registration, etc.). The ``"default"`` fallback is excluded
from every list because it is implicit, not a user-selectable name.
"""
try:
result = _enumerate_variants(task_name)
return {target: sorted(names) for target, names in result.items()}
except Exception:
return None
# ============================================================================
# Help-text rendering
# ============================================================================
class _PresetHelpFormatter(argparse.HelpFormatter):
"""Argparse help formatter that wraps each paragraph separately.
Default :class:`argparse.HelpFormatter` reflows the entire description into
one paragraph, merging the variant listing into the surrounding prose, and
collapses ``\\n``-separated bullets onto one line.
:class:`~argparse.RawDescriptionHelpFormatter` preserves description
newlines but drops wrapping entirely. The ``_fill_text`` override below
splits the description on blank lines and wraps each paragraph indep-
endently, giving both readable paragraphs and per-line bullets.
"""
def _fill_text(self, text: str, width: int, indent: str) -> str:
import textwrap
paragraphs = text.split("\n\n")
rendered: list[str] = []
for paragraph in paragraphs:
# A paragraph that already contains hard newlines (the bulleted
# variant listing) is rendered verbatim; otherwise word-wrap.
if "\n" in paragraph:
rendered.append("\n".join(f"{indent}{line}" for line in paragraph.splitlines()))
else:
rendered.append(textwrap.fill(paragraph, width, initial_indent=indent, subsequent_indent=indent))
return "\n\n".join(rendered)
class _DescriptionBuilder:
"""Renders the preset-selection ``argument_group`` description.
Groups the column constants and per-row formatting that build the
selector table. Iterates :class:`PresetTarget` to produce one row per
selector; each row's syntax and description come from the enum, so
adding a new typed target needs no changes here.
"""
# Column widths. ``SELECTOR_COL`` = width of the longest selector syntax
# (``presets=NAME[,NAME,...]`` = 23 chars); shorter selectors right-pad
# to this width. ``DESC_GAP`` is the gap between syntax and description.
SELECTOR_COL = 23
DESC_GAP = 3
ROW_PREFIX = " "
INTRO = "Select named PresetCfg alternatives via Hydra-style overrides (key=value, no leading dashes):"
EPILOG = "Hydra also accepts path-targeted overrides like env.sim.physics=NAME."
HINT = "Pass `--task=X` along with `--help` to see preset variants available for that task."
@classmethod
def build(cls, actual_variants: dict[PresetTarget, set[str]] | None) -> str:
"""Build the description text.
Args:
actual_variants: ``None`` when no ``--task=X --help`` is in argv;
otherwise a ``{target: set[name]}`` bucketed view from
:func:`_enumerate_variants`.
"""
with_available = actual_variants is not None
rows = [
cls._row(t, with_available=with_available, variants=sorted((actual_variants or {}).get(t, set())))
for t in PresetTarget
]
middle = f"{cls.HINT}\n\n" if not with_available else ""
return f"{cls.INTRO}\n" + "\n".join(rows) + f"\n\n{middle}{cls.EPILOG}"
@classmethod
def _row(cls, target: PresetTarget, *, with_available: bool, variants: list[str]) -> str:
syntax = cls._syntax(target).ljust(cls.SELECTOR_COL)
desc = cls._description(target)
suffix = ". Available:" if with_available else ""
header = f"{cls.ROW_PREFIX}{syntax}{' ' * cls.DESC_GAP}{desc}{suffix}"
if not with_available:
return header
# Bullet indent aligns with the description column once argparse
# prepends its 2-space group-description indent.
bullet_indent = " " * (len(cls.ROW_PREFIX) + cls.SELECTOR_COL + cls.DESC_GAP)
body = "\n".join(f"{bullet_indent}- {n}" for n in variants) if variants else f"{bullet_indent}(none)"
return f"{header}\n{body}"
@staticmethod
def _syntax(target: PresetTarget) -> str:
"""User-facing selector form: ``physics=NAME`` vs ``presets=NAME[,NAME,...]``."""
if target.base_classes: # typed: single name
return f"{target.value}=NAME"
return f"{target.value}=NAME[,NAME,...]" # DOMAIN: comma-separated broadcast
@staticmethod
def _description(target: PresetTarget) -> str:
"""One-line description of a selector's semantic target."""
if target.base_classes:
return f"(typed) selects a {target.value} backend"
return "broadcast: applied to every matching PresetCfg"
class _AgentDescriptionBuilder:
"""Render registered agent configs and declared preset compatibility."""
@staticmethod
def build(agent_library: str, task_name: str | None) -> str:
"""Build help text for one RL library.
Args:
agent_library: RL-library prefix used to filter agent configs.
task_name: Gymnasium task ID, or ``None`` when task-specific help
was not requested.
Returns:
Multi-line argparse group description.
"""
if task_name is None:
return (
f"Registered --agent values for {agent_library}. Pass `--task=X --help` "
"to see the available configs and declared preset compatibility."
)
agents, compatibility = _enumerate_agents(task_name, agent_library)
if not agents:
return f"Registered --agent values for {agent_library}: (none)"
lines = [f"Registered --agent values for {agent_library}:"]
for agent in agents:
suffix = " (default)" if agent == f"{agent_library}_cfg_entry_point" else ""
lines.append(f" {agent}{suffix}")
compatible = compatibility.get(agent)
if compatible is not None:
lines.append(f" compatible presets: {', '.join(compatible)}")
if not compatibility:
lines.extend(["", "Preset selection does not constrain --agent for this task."])
return "\n".join(lines)
# ============================================================================
# argv inspection (pre-argparse peek for help-text rendering)
# ============================================================================
def _auto_select_agent(
args: argparse.Namespace,
task_name: str,
agent_library: str,
argv: list[str],
) -> None:
"""Set ``args.agent`` when the task unambiguously implies one entry point.
Two independent selection rules are applied in order:
1. **Preset-based**: scans *argv* for ``presets=<name>`` tokens and checks
``agent_preset_compatibility``. When exactly one registered entry point
declares compatibility with every active preset, that entry point is used.
2. **Default-absent**: when no preset is active and the canonical default
entry point (``<library>_cfg_entry_point``) is not registered for the
task, but exactly one other entry point is, that sole entry point is used.
This handles tasks such as ``IsaacContrib-Humanoid-AMP-*`` that only
support a non-default algorithm (AMP) and never register the PPO default.
Does nothing when the match is absent or ambiguous.
Args:
args: Parsed namespace to update in-place.
task_name: Gymnasium task ID used to look up the registry spec.
agent_library: RL-library prefix (e.g. ``"skrl"``).
argv: Raw argument list scanned for ``presets=`` tokens.
"""
if getattr(args, "agent", None) is not None:
return
active_presets: set[str] = set()
for token in argv:
if token.startswith("presets="):
for name in token[len("presets=") :].split(","):
name = name.strip()
if name:
active_presets.add(name)
try:
agents, compatibility = _enumerate_agents(task_name, agent_library)
except Exception: # noqa: BLE001
return
if active_presets:
# Rule 1: preset-based selection via agent_preset_compatibility.
# Filter to only the presets that appear in the compatibility map: physics
# and renderer tokens can arrive via the presets= broadcast but are never
# declared as agent constraints, so including them would make issubset fail
# for every entry point and silently fall back to the wrong default.
all_declared = {p for declared in compatibility.values() for p in declared}
domain_presets = active_presets & all_declared
if domain_presets:
matches = [ep for ep, declared in compatibility.items() if domain_presets.issubset(set(declared))]
if len(matches) == 1:
args.agent = matches[0]
return
# Rule 2: default-absent selection.
default_ep = f"{agent_library}_cfg_entry_point"
if default_ep not in agents and len(agents) == 1:
args.agent = agents[0]
class _ArgvHelper:
"""Single-pass argv scan that exposes ``task_name`` and ``help_requested``.
Needed because argparse's ``--help`` short-circuits parsing, so help text
that depends on ``--task`` has to find it before argparse runs.
Attributes:
task_name: Last ``--task`` value (matching argparse's last-wins
semantics), or ``None`` if absent.
help_requested: ``True`` if ``--help`` or ``-h`` is present.
"""
def __init__(self, argv: list[str]):
self.task_name: str | None = None
self.help_requested: bool = False
for i in range(1, len(argv)):
token = argv[i]
if token in ("--help", "-h"):
self.help_requested = True
elif token == "--task" and i + 1 < len(argv):
self.task_name = argv[i + 1]
elif token.startswith("--task="):
self.task_name = token[len("--task=") :]
# ============================================================================
# Help-time variant enumeration (load env_cfg, walk, bucket by target)
# ============================================================================
def _enumerate_variants(task_name: str) -> dict[PresetTarget, set[str]]:
"""Load env_cfg for *task_name* and bucket its variants by target.
Uses the same walker hydra's resolver runs so help and resolve see one
view of the cfg tree. The env_cfg load is safe before AppLauncher boots
because ``test_env_cfg_no_forbidden_imports`` blocks Kit-only imports at
the top level of cfg modules. Exceptions from the loader propagate
verbatim -- they surface as the natural error, not a buried help string.
"""
from isaaclab_tasks.utils.hydra import collect_presets
from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry
env_cfg = load_cfg_from_registry(task_name, "env_cfg_entry_point")
return _bucket_variants_by_target(collect_presets(env_cfg))
def _enumerate_agents(task_name: str, agent_library: str) -> tuple[list[str], dict[str, tuple[str, ...]]]:
"""Return registered agents and task-declared preset compatibility."""
import gymnasium as gym
spec = gym.spec(task_name.split(":")[-1])
prefix = f"{agent_library}_"
agents = sorted(key for key in spec.kwargs if key.startswith(prefix) and key.endswith("_cfg_entry_point"))
compatibility = spec.kwargs.get("agent_preset_compatibility", {})
return agents, {agent: tuple(presets) for agent, presets in compatibility.items() if agent in agents}
def _bucket_variants_by_target(walked: dict) -> dict[PresetTarget, set[str]]:
"""Convert :func:`collect_presets` output into ``{target: set[name]}``.
Routes each ``(name, cfg)`` through :meth:`PresetTarget.matches`; cfgs
matching no typed target fall into ``DOMAIN``. The implicit ``default``
field is filtered -- it's the fallback, not a selectable name.
Direct routing by class hierarchy means new backends subclassing
:class:`~isaaclab.physics.PhysicsCfg` /
:class:`~isaaclab.renderers.renderer_cfg.RendererCfg` bucket automatically
regardless of what name the env_cfg gives the field. Targets may also
recognize documented container shapes through :meth:`PresetTarget.matches`.
"""
typed_targets = [t for t in PresetTarget if t.base_classes]
result: dict[PresetTarget, set[str]] = {target: set() for target in PresetTarget}
for path_dict in walked.values():
for name, cfg in path_dict.items():
if name == "default":
continue
matched = next(
(target for target in typed_targets if target.matches(cfg)),
PresetTarget.DOMAIN,
)
result[matched].add(name)
return result