[PresetCLI] Refactor preset into reusable class#5535
[PresetCLI] Refactor preset into reusable class#5535hujc7 wants to merge 1 commit intoisaac-sim:developfrom
Conversation
There was a problem hiding this comment.
Review Summary
Well-architected refactor that elevates preset selection from a Hydra implementation detail to a first-class CLI concept. The three-layer design (registry → cfg → CLI) provides good separation of concerns.
Strengths
preset_namedecorator keeps canonical names co-located with their cfg classes — prevents vocabulary drift- Kind taxonomy via class inheritance rather than name-matching is robust and extensible
- Backwards compatibility preserved: legacy
presets=...override andfrom isaaclab_tasks.utils.hydra import PresetCfgstill work - Comprehensive test coverage (616 lines) with edge cases for legacy aliases, unknown presets, and multi-kind validation
- Clean API surface:
setup_cli(parser)one-liner for script authors
Concerns
See inline comments. High-level:
- The
_BACKEND_MODULESforce-import list is a maintenance liability — adding a new backend requires editing this list PresetCli._task_loaderas a mutable class variable is a testing footgun (global state shared across test cases)- The
_user_stacklevel()function is duplicated across modules
| "isaaclab_newton.physics.kamino_manager_cfg", | ||
| "isaaclab_physx.renderers.isaac_rtx_renderer_cfg", | ||
| "isaaclab_newton.renderers.newton_warp_renderer_cfg", | ||
| "isaaclab_ov.renderers.ovrtx_renderer_cfg", |
There was a problem hiding this comment.
🟡 Hardcoded backend module list: _BACKEND_MODULES requires manual updates when new backends are added. Consider using entry points or a plugin registry pattern so backends self-register:
# In each backend's __init__.py or cfg module:
from isaaclab.utils.preset_meta import PresetRegistry
PresetRegistry.register(PresetKind.PHYSICS, "physx")(PhysxCfg)Then _ensure_backends_imported could discover registered entry points instead of maintaining a hardcoded list. This is a longer-term suggestion — the current approach is fine for now given the small number of backends.
| """ | ||
| cls._task_loader = loader | ||
|
|
||
| # -- public API: per-script wiring ------------------------------------ |
There was a problem hiding this comment.
🟡 Mutable class variable for task loader: _task_loader: ClassVar[Any] = None is mutable global state that persists across test cases. If tests register different loaders or forget to reset, they can leak state.
Consider using a contextmanager or explicit reset for testing:
@classmethod
@contextlib.contextmanager
def override_task_loader(cls, loader):
prev = cls._task_loader
cls._task_loader = loader
try:
yield
finally:
cls._task_loader = prev| the helper that emitted the warning. | ||
|
|
||
| Kept module-private because it inspects ``__file__`` of *this* module; | ||
| the call chain on the CLI side has its own copy in ``hydra.py``. |
There was a problem hiding this comment.
💡 _user_stacklevel duplication: This helper appears in both preset_cfg.py and hydra.py. Consider extracting it to a shared utility (e.g., isaaclab.utils._warnings):
# isaaclab/utils/_warnings.py
def stacklevel_outside(module_file: str, max_walk: int = 16) -> int: ...Minor, but reduces the chance of the two implementations drifting.
| obj.legacy_aliases = dict(legacy_aliases) if legacy_aliases else {} | ||
| return obj | ||
|
|
||
| PHYSICS = ("physics", {"newton": "newton_mjwarp", "kamino": "newton_kamino"}) |
There was a problem hiding this comment.
💡 PresetKind enum with dual-value constructor: The __new__ override with (value, legacy_aliases) tuple is clever but non-obvious. A first-time reader seeing PHYSICS = ("physics", {"newton": "newton_mjwarp", ...}) might not immediately understand the structure.
Consider adding a brief code comment above the first member:
# Members: (label_for_CLI_flag, {deprecated_alias: canonical_replacement})
PHYSICS = ("physics", {"newton": "newton_mjwarp", "kamino": "newton_kamino"})| from isaaclab.envs.utils.spaces import replace_env_cfg_spaces_with_strings, replace_strings_with_env_cfg_spaces | ||
|
|
||
| # ``configclass`` is imported here (and re-exported via __all__) so existing code that does | ||
| # ``from isaaclab_tasks.utils.hydra import configclass`` keeps working after the preset |
There was a problem hiding this comment.
💡 Good backwards compat: Re-exporting PresetCfg, preset, and collect_presets from their new home while keeping the old import path working is the right call. Consider adding a deprecation note in the module docstring or via __deprecated__ so IDEs can flag the old imports:
# Deprecated re-exports — use isaaclab.utils.preset_cfg directly in new code.Minimal layer on top of the existing Hydra preset flow. The typed flags
translate to the same ``presets=<csv>`` token the develop-branch
register_task / apply_overrides path already understands; no changes to
hydra.py, PresetCfg, or the per-target override semantics.
Components:
- ``isaaclab/utils/preset_registry.py`` (new): ``PresetTarget`` enum
(PHYSICS / RENDERER / DOMAIN) with per-member legacy alias dicts,
plus ``PresetRegistry`` class holding the ``{target: {name: cls}}``
map and the ``register`` decorator. Single source of truth for
targets and their canonical names.
- ``isaaclab_tasks/utils/preset_cli.py`` (new): ``setup_cli(parser)``
function. Adds one argparse flag per PresetTarget (DOMAIN gets the
``--presets`` catch-all CSV; others get ``--{target.value}``).
Loads the task's env config from ``--task=X`` to populate the
registry, validates each typed flag against it (rejects unknown
names with a list of valid options), normalizes legacy aliases with
a single FutureWarning, then folds everything into one
``presets=<csv>`` token at the front of ``sys.argv``. A custom
``_HelpAction`` enriches ``--help`` output with the task's actual
registered preset names.
- 7 backend cfg files: ``@register(PresetTarget.X, "name")``
decorator above the existing ``@configclass``. Two new lines per
file (decorator + import). The decorator attaches ``_preset_name``
/ ``_preset_target`` to the class.
- ``isaaclab_tasks/test/test_preset_cli.py`` (new): 17 tests covering
the translator, decorator binding, validation, alias normalization,
``--help`` enrichment, and a cross-env drift lint that walks every
registered Isaac- task to check PresetCfg subclasses use canonical
field names.
What this does NOT do (intentionally): it does not refactor
``hydra.py``, does not move ``PresetCfg`` out of
``isaaclab_tasks.utils.hydra``, and does not introduce a ``PresetCli``
class, ``PresetOverrides`` dataclass, ``_PresetRequest`` dataclass, or
parser-state IntFlag. The translator is a single function. Adding a
new preset target = appending one ``PresetTarget`` enum member; no
second list elsewhere needs updating.
Tests: 93 passed (76 original hydra tests + 17 new).
fcf4134 to
f2683fc
Compare
There was a problem hiding this comment.
🤖 Isaac Lab Review Bot
Summary
Well-architected PR that introduces a decorator-driven preset registry (preset_registry.py) and typed CLI translator (preset_cli.py). Backend cfg classes self-register via @register(PresetTarget.PHYSICS, "physx"), and the CLI layer translates --physics physx into the existing presets=<csv> Hydra token. The module placement is correct (registry in isaaclab.utils to avoid circular deps, CLI in isaaclab_tasks.utils), backwards compatibility is preserved, and tests are comprehensive. Three issues should be addressed before merge.
Design Assessment
Design is sound. The decorator-driven registry is the right pattern — canonical names live next to the cfg class, the CLI layer has no hardcoded vocabulary, and the existing presets= Hydra flow is unchanged. Module placement correctly respects the dependency DAG.
Findings
🟡 Warning: _load_task_backends catches all exceptions silently — source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py:59-66
The bare except Exception: pass swallows ImportError, SyntaxError, and any other error from backend cfg imports. When a backend fails to load (e.g., missing dependency, broken import), the user sees "(no backends registered for this task)" instead of the actual error. This is especially confusing when combined with _validate_typed_flag — a user typing --physics physx gets rejected with a misleading "not recognized" message when the real problem is a broken import.
Recommendation: Narrow the catch to expected failures, or at minimum log the exception:
except (ValueError, KeyError):
pass # Unknown task — Hydra will surface later
except Exception as exc:
import logging
logging.getLogger(__name__).debug(
"Failed to load backends for %r: %s", task_name, exc
)🟡 Warning: Duplicate legacy alias definitions — two sources of truth — source/isaaclab/isaaclab/utils/preset_registry.py:60
The legacy alias map {"newton": "newton_mjwarp", "kamino": "newton_kamino"} now exists in PresetTarget.PHYSICS.legacy_aliases AND in _LEGACY_PRESET_ALIASES in hydra.py (unchanged by this PR). These are consumed by different code paths — the new CLI (PresetTarget.normalize()) and the existing Hydra flow (_normalize_preset_name()). If a maintainer adds a new alias, they must update both locations or behavior diverges between --physics newton and presets=newton.
Recommendation: Either make hydra.py read from PresetTarget.legacy_aliases (single source of truth), or add a CI test asserting the two maps are consistent.
🟡 Warning: Missing __init__.pyi / __all__ updates — new modules not discoverable via top-level imports
The codebase uses lazy_loader.attach_stub() which resolves exports from .pyi stubs. Without updates to isaaclab/utils/__init__.pyi and isaaclab_tasks/utils/__init__.pyi, from isaaclab.utils import PresetTarget won't work — users must use the full submodule path. The backend cfg files already use full paths so this isn't a runtime blocker, but setup_cli especially should be a top-level export of isaaclab_tasks.utils.
🔵 Suggestion: Add PresetRegistry._reset_for_testing() classmethod — source/isaaclab/isaaclab/utils/preset_registry.py
The mutable ClassVar _entries is process-global state populated by import-time side effects. The test test_register_rejects_duplicate_binding permanently adds _test_unique_a to the registry. While the current tests work (force-imports at module level), a cleanup mechanism would improve test isolation for future tests and avoid issues with pytest --count=2 or parallel test runners.
🔵 Suggestion: stacklevel=3 in normalize() may point to internal code — source/isaaclab/isaaclab/utils/preset_registry.py:85
The FutureWarning about deprecated aliases uses stacklevel=3. Depending on the call depth from _validate_typed_flag → normalize(), this may point to preset_cli.py internals rather than the user's script. Verify the warning attribution points to a useful location for the user.
Test Coverage
- CLI translation and flag merging: ✅ Thorough (10+ tests)
- Registry decorator and validation: ✅ Good
- Legacy alias normalization: ✅ Covered
- Drift detection across all tasks: ✅ Excellent CI guard
--rendererflag in isolation:⚠️ Only tested in combination_load_task_backendsfailure path: ❌ Not tested- Drift test minimum-tasks guard:
⚠️ Could pass vacuously if no tasks load
CI Status
Most checks pending.
Verdict
Minor fixes needed
Excellent architecture and implementation. The three warning-level items (silent except, duplicate alias maps, missing stub exports) should be addressed to prevent maintenance drift and debugging confusion. The suggestion items are non-blocking improvements.
Goal
Make preset selection a first-class CLI surface. Replace bare
presets=newton_mjwarpHydra-style overrides with typed flags (--physics,--renderer,--presets) that show up in--help, validate before Hydra runs, and migrate legacy names with a deprecation warning.The PR is a small additive layer on top of the existing Hydra preset flow. It does not refactor
hydra.py, movePresetCfg, or change the override semantics. Typed flags translate to the samepresets=<csv>token thatregister_task/apply_overridesalready understand.Design (single source of truth)
PresetTargetenum — every CLI-flag category lives here, with its legacy aliases attached to the enum member:Adding a new flag = appending one enum member.
setup_clidiscovers it viafor target in PresetTarget; no second list elsewhere needs updating.PresetRegistryclass — container +registerdecorator + lookups, all together:Backend cfg classes declare themselves once:
setup_cli(parser)— single function inisaaclab_tasks/utils/preset_cli.py. Adds one argparse flag perPresetTarget(DOMAINgets--presetscatch-all CSV; others get--{target.value}). Loads the task's env config from--task=Xto populate the registry, validates each typed flag against it, normalizes legacy aliases with a singleFutureWarning, then folds everything into onepresets=<csv>token at the front ofsys.argv.Before / after
CLI surface
python train.py --task=... presets=newton_mjwarp,newton_renderer(Hydra-style, no--, undiscoverable in--help)python train.py --task=... --physics=newton_mjwarp --renderer=newton_renderer. Both--flag valueand--flag=valuework. Legacypresets=...form still works.--helplisting--help.--task=<X> --helplists per-task valid names per flag.newton)FutureWarning+ auto-rewrite tonewton_mjwarp.Error message (after)
--help (after)
Env author experience
Same
PresetCfgsyntax. New CI lint walks the gym registry and asserts that every PresetCfg subclass uses canonical field names where the alternative's value type is bound to a canonical (target, name). Catches drift likefoo: PhysxCfg = PhysxCfg()(instead of the canonicalphysx:).Backend author experience
Adding a new physics solver:
@register(PresetTarget.PHYSICS, "newton_fastsolver")--help, validation accepts it, drift lint approves field namingNo changes needed to
preset_cli.pyor any vocabulary list.What's not changed
env.sim.dt=0.001,agent.seed=42) untouched.presets=newton_mjwarp,...global broadcast still works exactly as before.PresetCfgstays where it is inisaaclab_tasks/utils/hydra.py; no relocation.hydra.pyis not modified by this PR.setup_cli(parser)in place of their existing argparse boilerplate.Files changed (10 files, +714/−0)
source/isaaclab/isaaclab/utils/preset_registry.pysource/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py@register)physx_manager_cfg.py,ovphysx_manager_cfg.py,mjwarp_manager_cfg.py,kamino_manager_cfg.py,isaac_rtx_renderer_cfg.py,newton_warp_renderer_cfg.py,ovrtx_renderer_cfg.pysource/isaaclab_tasks/test/test_preset_cli.py(17 tests, including the cross-env drift lint)Test plan
./isaaclab.sh -p -m pytest source/isaaclab_tasks/test/test_preset_cli.py— 17/17 passed./isaaclab.sh -p -m pytest source/isaaclab_tasks/test/test_hydra.py— 76/76 passed (unchanged)./isaaclab.sh -f) clean--task=Isaac-Velocity-Flat-Anymal-C-v0 --helplists--physics: newton_mjwarp, physx--physics=super_solverrejected pre-Hydra with the actual valid names--physics=newton(legacy) emitsFutureWarningand rewrites tonewton_mjwarpReference: alternative design preserved
A larger refactoring iteration of this work — including
PresetCliclass,PresetOverrides/_PresetRequestdataclasses,_ParserStateIntFlag, hydra.py refactor,PresetCfghoisted to core, naming-pass globally — is preserved on a separate branch for reference: https://github.com/hujc7/IsaacLab/tree/jichuanh/preset-cli-full-synthesis. That branch is not part of this PR; it exists only as a record of what an end-to-end refactor of the preset surface would look like.