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
15 changes: 15 additions & 0 deletions source/isaaclab/changelog.d/jichuanh-preset-cli.minor.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Added
^^^^^

* Added :mod:`isaaclab.utils.preset_registry` which exposes
:class:`~isaaclab.utils.preset_registry.PresetTarget` (closed enum of
preset categories: ``physics``, ``renderer``, ``domain``) and
:class:`~isaaclab.utils.preset_registry.PresetRegistry` (a per-target
``{name: cls}`` map plus the
:meth:`~isaaclab.utils.preset_registry.PresetRegistry.register`
decorator). Backend cfg classes can now declare their canonical preset
name with ``@register(PresetTarget.PHYSICS, "physx")``, so consumers
(typed CLI flags, drift lints, ``--help`` listings) can discover the
available presets without a hard-coded second list. Legacy CLI alias
normalization (e.g. ``newton`` -> ``newton_mjwarp``) is part of each
enum member, with deprecation surfaced via :exc:`FutureWarning`.
161 changes: 161 additions & 0 deletions source/isaaclab/isaaclab/utils/preset_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# 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

"""Canonical preset name registry.

Two things live here:

* :class:`PresetTarget` -- the closed enum of preset categories. Each
member carries its CLI-flag label and its (optional) per-target dict
of legacy aliases. Adding a category = appending one enum member.
* :class:`PresetRegistry` -- the ``{target: {name: cls}}`` container plus
``register`` decorator and lookups. All state + access on one class.

The module-level :func:`register` alias keeps the natural decorator
spelling at the call site.

Example::

from isaaclab.utils.preset_registry import PresetTarget, register


@register(PresetTarget.PHYSICS, "physx")
@configclass
class PhysxCfg(PhysicsCfg): ...

This module lives in :mod:`isaaclab.utils` so backend packages
(``isaaclab_physx``, ``isaaclab_newton``, ...) can decorate their cfg
classes without taking a dependency on :mod:`isaaclab_tasks`.
"""

from __future__ import annotations

import enum
import warnings
from typing import ClassVar


class PresetTarget(enum.Enum):
"""CLI-flag target categories.

Each member's value is ``(label, legacy_aliases)``:

* ``label`` -- the lowercase CLI flag string. ``--{label}`` becomes
the typed flag for non-DOMAIN targets; ``DOMAIN`` is the catch-all
that maps to ``--presets`` and is never validated.
* ``legacy_aliases`` -- mapping of deprecated preset names to their
canonical replacements within this target. Optional; targets with
no legacy names omit it.

Adding a new target = appending one enum member; ``setup_cli`` and
``PresetRegistry`` discover it via iteration. No second list to update.
"""

def __new__(cls, label: str, legacy_aliases: dict[str, str] | None = None):
obj = object.__new__(cls)
obj._value_ = label
# Per-instance attribute so it survives the enum machinery.
obj.legacy_aliases = dict(legacy_aliases) if legacy_aliases else {}
return obj

# Members. Tuple values are (label, legacy_aliases).
PHYSICS = ("physics", {"newton": "newton_mjwarp", "kamino": "newton_kamino"})
"""Physics backends -- ``--physics`` flag. Legacy: ``newton``, ``kamino``."""

RENDERER = ("renderer",)
"""Camera-sensor renderers -- ``--renderer`` flag."""

DOMAIN = ("domain",)
"""Free-form env-specific presets -- ``--presets`` flag (catch-all). Not validated."""

def normalize(self, name: str) -> str:
"""Resolve a legacy alias for this target to its canonical name.

Returns *name* unchanged if it is not a legacy alias of this target.
Otherwise emits a :class:`FutureWarning` and returns the canonical
replacement.
"""
if name in self.legacy_aliases:
canonical = self.legacy_aliases[name]
# stacklevel=4 = warn() -> normalize() -> _validate_typed_flag()
# -> setup_cli() -> user's train.py. Lands the warning on the
# user's setup_cli(...) call instead of inside this module.
warnings.warn(
f"--{self.value} {name!r} is deprecated. Use {canonical!r} instead.",
FutureWarning,
stacklevel=4,
)
return canonical
return name


class PresetRegistry:
"""``(target, name) → class`` map. Container + register + lookups on one class.

Populated at backend-cfg import time by the :meth:`register` decorator.
The module-level :data:`register` alias is the canonical decorator
call form: ``@register(PresetTarget.PHYSICS, "physx")``.
"""

# {target: {name: cls}}. ClassVar so it's class-level state, not per-instance.
_entries: ClassVar[dict[PresetTarget, dict[str, type]]] = {}

@classmethod
def register(cls, target: PresetTarget, name: str):
"""Decorator: bind ``(target, name)`` to a config class.

The decorated class gains ``_preset_name`` (str) and
``_preset_target`` (PresetTarget) attributes for later lookup.

Stamping is per-class -- the guard checks ``target_cls.__dict__``
only -- so:

* **Chained decoration of the same class** (rare, usually a
mistake) preserves the *first* binding's canonical attributes.
Decorators apply bottom-up, so the inner ``@register`` runs
first, sets the attribute, and the outer ``@register`` sees
``__dict__`` already populated and skips stamping. The outer
name is still added to the registry so it resolves, but
``cls._preset_name`` reads back to the inner one.
* **Decorated subclass** gets its *own* canonical -- the
subclass ``__dict__`` doesn't yet contain ``_preset_name``
(parent's value is reachable via MRO but not via ``__dict__``),
so the stamp succeeds. Decorating a subclass with a different
name is a deliberate "new preset" declaration and shadows the
parent canonical at the subclass level.

Raises:
RuntimeError: If ``(target, name)`` is already bound to a different class.
"""

def deco(target_cls: type) -> type:
existing = cls._entries.get(target, {}).get(name)
if existing is not None and existing is not target_cls:
raise RuntimeError(
f"@register({target!r}, {name!r}) already bound to"
f" {existing.__module__}.{existing.__name__}; cannot rebind to"
f" {target_cls.__module__}.{target_cls.__name__}."
)
cls._entries.setdefault(target, {})[name] = target_cls
# Stamp only when this exact class doesn't already carry a
# canonical of its own. Chained decoration: first one wins.
# Decorated subclass: gets its own (parent's value is inherited
# but not in this class's __dict__).
if "_preset_name" not in target_cls.__dict__:
target_cls._preset_name = name # type: ignore[attr-defined]
target_cls._preset_target = target # type: ignore[attr-defined]
return target_cls

return deco

@classmethod
def names_for(cls, target: PresetTarget) -> set[str]:
"""Canonical names registered for *target*."""
return set(cls._entries.get(target, ()))


# Decorator alias kept at module level for the natural decorator spelling.
register = PresetRegistry.register
"""Decorator alias for :meth:`PresetRegistry.register`."""
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import TYPE_CHECKING

from isaaclab.utils import configclass
from isaaclab.utils.preset_registry import PresetTarget, register

from .newton_manager_cfg import NewtonSolverCfg

Expand All @@ -19,6 +20,7 @@
from isaaclab_newton.physics import NewtonManager


@register(PresetTarget.PHYSICS, "newton_kamino")
@configclass
class KaminoSolverCfg(NewtonSolverCfg):
"""Configuration for Kamino solver-related parameters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
from typing import TYPE_CHECKING

from isaaclab.utils import configclass
from isaaclab.utils.preset_registry import PresetTarget, register

from .newton_manager_cfg import NewtonSolverCfg

if TYPE_CHECKING:
from isaaclab_newton.physics import NewtonManager


@register(PresetTarget.PHYSICS, "newton_mjwarp")
@configclass
class MJWarpSolverCfg(NewtonSolverCfg):
"""Configuration for MuJoCo Warp solver-related parameters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@

from isaaclab.renderers.renderer_cfg import RendererCfg
from isaaclab.utils import configclass
from isaaclab.utils.preset_registry import PresetTarget, register


@register(PresetTarget.RENDERER, "newton_renderer")
@configclass
class NewtonWarpRendererCfg(RendererCfg):
"""Configuration for Newton Warp Renderer."""
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

from isaaclab.renderers.renderer_cfg import RendererCfg
from isaaclab.utils import configclass
from isaaclab.utils.preset_registry import PresetTarget, register


@register(PresetTarget.RENDERER, "ovrtx_renderer")
@configclass
class OVRTXRendererCfg(RendererCfg):
"""Configuration for OVRTX Renderer.
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@

from isaaclab.physics import PhysicsCfg
from isaaclab.utils import configclass
from isaaclab.utils.preset_registry import PresetTarget, register


@register(PresetTarget.PHYSICS, "ovphysx")
@configclass
class OvPhysxCfg(PhysicsCfg):
"""Configuration for the ovphysx physics manager.
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@

from isaaclab.physics import PhysicsCfg
from isaaclab.utils import configclass
from isaaclab.utils.preset_registry import PresetTarget, register

if TYPE_CHECKING:
from .physx_manager import PhysxManager


@register(PresetTarget.PHYSICS, "physx")
@configclass
class PhysxCfg(PhysicsCfg):
"""Configuration for PhysX physics manager.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@

from isaaclab.renderers.renderer_cfg import RendererCfg
from isaaclab.utils import configclass
from isaaclab.utils.preset_registry import PresetTarget, register


@register(PresetTarget.RENDERER, "isaacsim_rtx_renderer")
@configclass
class IsaacRtxRendererCfg(RendererCfg):
"""Configuration for Isaac RTX renderer using Omniverse Replicator.
Expand Down
20 changes: 20 additions & 0 deletions source/isaaclab_tasks/changelog.d/jichuanh-preset-cli.minor.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Added
^^^^^

* Added :func:`isaaclab_tasks.utils.preset_cli.setup_cli`, a typed-flag
layer in front of the existing Hydra-decorator preset flow. Scripts
call ``setup_cli(parser)`` once and gain ``--physics=NAME``,
``--renderer=NAME``, and free-form ``--presets=NAME[,NAME,...]``; the
flags are translated to a single ``presets=<csv>`` Hydra token, so the
downstream resolver path is unchanged. ``setup_cli`` validates each
typed flag against
:meth:`~isaaclab.utils.preset_registry.PresetRegistry.names_for`
unioned with the field names found on the selected task's
:class:`~isaaclab_tasks.utils.hydra.PresetCfg` instances, so users can
define variant alternatives (e.g. ``newton_mjwarp_strict: MjwarpCfg``)
alongside the canonical-named field without re-decorating the cfg
class. ``--help`` lists the valid choices per target, scoped to the
task when ``--task=<X>`` is supplied. Legacy alias inputs
(``--physics newton`` -> ``newton_mjwarp``;
``--physics kamino`` -> ``newton_kamino``) are normalized with a
:exc:`FutureWarning`.
Loading
Loading