Skip to content

[PresetCLI] Refactor preset into reusable class#5535

Draft
hujc7 wants to merge 16 commits intoisaac-sim:developfrom
hujc7:jichuanh/preset-cli
Draft

[PresetCLI] Refactor preset into reusable class#5535
hujc7 wants to merge 16 commits intoisaac-sim:developfrom
hujc7:jichuanh/preset-cli

Conversation

@hujc7
Copy link
Copy Markdown
Collaborator

@hujc7 hujc7 commented May 7, 2026

Goal

Make preset selection a first-class, discoverable, and drift-resistant CLI surface — replacing the bare presets=... Hydra-style override with typed flags that work uniformly across every IsaacLab script.

Key design choices

  1. Canonical names live next to the cfg class via a decorator. Each backend config class declares its preset name once, in the same file as the class. The CLI layer reads the registry; preset_cli has no hardcoded vocabulary list.

    # source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py
    @preset_name("physx")
    @configclass
    class PhysxCfg(PhysicsCfg):
        ...
  2. Closed vocabulary for --physics and --renderer; free-form for --presets. Physics and renderer names are project-wide concepts (small set, shared meaning across envs). Domain presets are intentionally env-specific. Drift on the canonical names is enforced by a CI lint; domain names are author-discretion.

  3. Single validation surface. Both "name not in canonical vocabulary" and "name not defined for this task" errors come from one code path with consistent formatting. argparse stays open (no choices=) so the friendly error never collides with the terse argparse default.

  4. Loose canonical-name rule, not strict. Inside a PresetCfg, every canonical type that appears must have at least one field named after its canonical name. Variants beyond that (e.g. physx_high_fidelity = PhysxCfg(...)) stay free-form. So envs can expose multiple PhysX tunings as long as the canonical physx entry is also present.

  5. One-line script wiring. setup_cli(parser) collapses argparse + launcher + parse + apply + sys.argv mutation into a single line. Scripts with mid-flow argv intercepts (e.g. rsl_rl/train.py's --external_callback) opt into commit=False and finish with preset_cli.commit(args, remaining) — never touching sys.argv directly.

  6. Kind detection is structural, not name-based. Whether a preset is "physics", "renderer", or "domain" comes from class inheritance (PhysicsCfg / RendererCfg / else). A class can be canonical-named without being one of those kinds (e.g. solver-dispatch types like MJWarpSolverCfg), and the CLI handles the dispatch via value.solver_cfg.

Before / after

CLI surface

Before this PR After this PR
Selecting a backend 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 (typed flags shown in --help). Legacy presets=... form still works.
Both syntactic forms n/a (only bare form) --flag value and --flag=value both work natively. --presets accepts CSV.
--help listing preset names absent — user has to read the env source typed flags appear in --help. With --task=<task> --help the output also lists per-task valid names, grouped by flag, one kind per line.

Error messages

Before — generic, mixed-kind:

ValueError: Unknown preset(s): newton_kamino

Available presets (grouped by affected paths):

  newton_mjwarp, physx
    -> env.sim.physics
  rgb, depth, albedo
    -> env.tiled_camera
  ...

After — kind-specific, single-place, with consistent formatting:

error: --physics 'newton_kamino' is not defined for task 'Isaac-Velocity-Flat-Anymal-C-v0'.

  This task currently defines:
    --physics:  newton_mjwarp, physx

  Recognized physics presets across IsaacLab (any task may add):
    newton_kamino, newton_mjwarp, ovphysx, physx

A wrong-kind name (e.g. --physics albedo) gets a flag suggestion: "'albedo' is defined as a domain preset on this task; did you mean '--presets albedo'?"

Env author experience

Before — every PresetCfg name was free-form Python; nothing prevented one env from naming the Newton MJWarp variant mjwarp while another called it newton_mjwarp. Drift on canonical names was undetectable.

After — same PresetCfg syntax, but a CI lint walks the gym registry and asserts every PresetCfg containing a canonical-typed alternative has the canonical name as one of its fields. A future PR adding super_solver_v2 = NewtonCfg(MJWarpSolverCfg(...)) to some env config fails CI with:

PhysicsCfg: alternative(s) (super_solver_v2) hold values of canonical type
'newton_mjwarp', but no field is named 'newton_mjwarp'. Rename one of these
fields to 'newton_mjwarp' so the canonical CLI form works for this task.

This PR also lands the small drift fix for G1/Go1/Spot velocity-flat envs that the lint surfaced — adds a physx = default alias so --physics physx works on those tasks too.

Backend author experience

Adding a new physics solver "FastNewton":

Step Before this PR After this PR
1. Define class class FastNewtonSolverCfg(NewtonSolverCfg): ... same
2. Bind canonical name (no concept) one line: @preset_name("newton_fastsolver") above the class
3. Wire in env config class MyPhysicsCfg(PresetCfg): newton_fastsolver = NewtonCfg(...) same
4. Update preset_cli vocab (no concept) not needed — registry picks up the decorator at import time
5. CI lint passes silently regardless passes — name matches the decorator
6. User CLI works only if user already knows the name name appears in --help automatically

Script wiring

Before (every script copies 5+ lines of boilerplate):

parser = argparse.ArgumentParser(...)
# ... script-specific args ...
add_launcher_args(parser)
args_cli, hydra_args = parser.parse_known_args()
sys.argv = [sys.argv[0]] + hydra_args

After (one line):

parser = argparse.ArgumentParser(...)
# ... script-specific args ...
args_cli = setup_cli(parser)

For scripts with mid-flow argv intercepts (e.g. rsl_rl/train.py):

args_cli, remaining_args, preset_cli = setup_cli(parser, commit=False)
# ... external-callback logic mutates remaining_args ...
preset_cli.commit(args_cli, remaining_args)

What's not changed

  • Hydra config-override semantics (env.sim.dt=0.001, agent.seed=42, etc.) are untouched.
  • The presets=newton_mjwarp,... global broadcast still works exactly as before — typed flags translate into it internally, so nothing the existing test suite or workflow assumes is broken.
  • PresetCfg structure is free-form. The decorator is on the leaf cfg classes (PhysxCfg, MJWarpSolverCfg, ...) — env authors writing PresetCfg subclasses don't change anything.

Files

Area Files
New decorator + registry source/isaaclab/isaaclab/utils/preset_meta.py
Rewritten CLI helper source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py
Backend cfg decorators (one line each) 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.py
Drift fix surfaced by the lint g1/flat_env_cfg.py, go1/flat_env_cfg.py, spot/flat_env_cfg.py
Reference script wiring scripts/reinforcement_learning/rsl_rl/train.py, scripts/environments/zero_agent.py
Non-Hydra path parse_cfg.py (added presets= kwarg)
Tests source/isaaclab_tasks/test/test_preset_cli.py (41 tests, including registry-walk drift lint)
Docs docs/source/features/hydra.rst
Changelogs source/isaaclab/changelog.d/, source/isaaclab_tasks/changelog.d/

Test plan

  • ./isaaclab.sh -p -m pytest source/isaaclab_tasks/test/test_preset_cli.py — 41/41 passed locally
  • Pre-commit (./isaaclab.sh -f) passes
  • describe_task('Isaac-Cartpole-Camera-Presets-Direct-v0') smoke produces the expected per-task listing
  • --physics newton_mjwarp against an env without a physics preset emits the targeted error
  • python scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Cartpole-Camera-Presets-Direct-v0 --physics newton_mjwarp --renderer newton_renderer --headless runs to completion (full Kit launch)
  • python scripts/environments/zero_agent.py --task=Isaac-Cartpole-Camera-Presets-Direct-v0 --physics=physx --headless works (the = form)

Preset selection currently happens through bare ``presets=foo`` Hydra
overrides, which are not discoverable from ``--help`` and provide a
single error message regardless of whether the user typed a physics,
renderer, or domain preset name.

This change introduces ``isaaclab_tasks.utils.PresetCli``, a reusable
argparse helper that exposes preset selection as three typed flags:

  --physics NAME            (PhysicsCfg subclasses)
  --renderer NAME           (RendererCfg subclasses)
  --presets NAME[,NAME...]  (everything else)

Both ``--flag value`` and ``--flag=value`` forms are accepted. The kind
of each preset is inferred from the type of its alternatives, so envs
do not need any per-task wiring beyond the existing PresetCfg
declarations. Internally the flags are translated into the same
``presets=<csv>`` global broadcast that ``register_task`` already
consumes, so behavior matches the legacy form.

When the user passes ``--task=<task> --help`` the argparse output is
augmented with a per-task listing of valid preset names grouped by
flag, and unknown names raise a kind-specific error that lists only
the relevant alternatives (a wrong-kind name suggests the right flag
to use).

For the non-Hydra path, ``parse_env_cfg`` now accepts a ``presets``
keyword that is forwarded to ``resolve_presets``.

Wired into ``scripts/reinforcement_learning/rsl_rl/train.py`` and
``scripts/environments/zero_agent.py`` as reference integrations.
@github-actions github-actions Bot added documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team labels May 7, 2026
@hujc7 hujc7 changed the title Add typed --physics/--renderer/--presets CLI flags [PresetCli] Add typed --physics/--renderer/--presets CLI flags May 7, 2026
@hujc7 hujc7 changed the title [PresetCli] Add typed --physics/--renderer/--presets CLI flags [PresetCLI] Make backend / renderer / preset selection explicit via typed --physics/--renderer/--presets flags May 7, 2026
@hujc7 hujc7 changed the title [PresetCLI] Make backend / renderer / preset selection explicit via typed --physics/--renderer/--presets flags [PresetCLI] Refactor preset into reusable class May 7, 2026
hujc7 added 6 commits May 8, 2026 21:46
Replace the type-only kind classification with a decorator-based binding
that pins each canonical preset name to the cfg class it represents.

  @preset_name("physx")
  @configclass
  class PhysxCfg(PhysicsCfg):
      ...

The CLI layer (PresetCli) reads the registry populated by the decorators
to:

* validate that user input matches the canonical vocabulary across all
  IsaacLab envs (no per-env drift on names like physx, newton_mjwarp,
  newton_kamino, ovphysx, isaacsim_rtx_renderer, newton_renderer,
  ovrtx_renderer);
* surface that vocabulary in argparse --help, with per-task subsets
  appearing when --task=<task> --help is used;
* run a CI lint (validate_preset_cfg) over every registered task config
  asserting that any PresetCfg containing canonical-typed alternatives
  has the canonical name as one of its field names. Variants beyond the
  canonical entry (e.g. physx_high_fidelity = PhysxCfg(...)) remain
  free-form.

Wire-up details:

* The decorator lives in isaaclab.utils.preset_meta -- dependency-light,
  no Isaac Sim imports.
* preset_cli eagerly imports the seven decorated backend cfg modules at
  startup so the registry is fully populated before --help reads it
  (gym registry alone is lazy and would not trigger them).
* setup_cli(parser) collapses the standard preset+launcher argparse
  wiring into a single line; opting out into commit=False mode returns
  (args, remaining, preset_cli) for scripts with mid-flow argv intercepts
  like rsl_rl/train.py's --external_callback.
* parse_env_cfg gains a presets= kwarg so non-Hydra entry points can
  apply preset selection from PresetCli.collect_selected().

Listing format: each kind on its own line in --help and error messages,
comma-separated names within a line; bullet lists used only for long
domain vocabularies. Replaces the {a, b, c} group syntax that crowded
unrelated names together.

Drift fix bundled with the lint: g1/go1/spot flat-env PhysicsCfg now
exposes the canonical 'physx = default' alias alongside 'newton_mjwarp',
matching the convention used everywhere else in the repo.
Replace the standalone module-level _REGISTRY dict + decorator + lookup
helpers with a PresetRegistry class that owns the registry as a class
variable and exposes the operations as classmethods. Free-function aliases
(preset_name, canonical_name_for, all_canonical_names, class_for_canonical_name)
remain at module level so decorator sites stay terse:

    @preset_name("physx")
    @configclass
    class PhysxCfg(PhysicsCfg):
        ...

Both forms hit the same single source of truth. Tab-completing
PresetRegistry. now surfaces every operation in one discoverable home,
addressing the "hard to track separate functions" concern.
PresetCfg, the preset() factory, the generic walkers (_preset_fields,
_walk_cfg, collect_presets), and the entire preset_cli helper are
generic primitives that don't depend on Hydra or gym. They previously
lived in isaaclab_tasks.utils because that's where Hydra integration is,
but the layering is wrong: cfg classes in isaaclab_physx / isaaclab_newton
are decorated with @preset_name (which lives in core) and produce values
of type PresetCfg (which lived in tasks), forcing a cross-layer reference
to read the canonical-name validator.

This change splits the file into:

  isaaclab.utils.preset_cfg
    PresetCfg, preset(), _preset_fields, _walk_cfg, collect_presets,
    _LEGACY_PRESET_ALIASES (the deprecation map shared by PresetCfg's
    __getattr__ alias channel and the CLI normalize hook).

  isaaclab.utils.preset_cli
    PresetCli, setup_cli, validate_preset_cfg, kind classification,
    error formatting. _enumerate lazy-imports load_cfg_from_registry
    (the only gym-coupled call); setup_cli calls
    AppLauncher.add_app_launcher_args directly instead of going through
    the isaaclab_tasks wrapper.

  isaaclab_tasks.utils.hydra
    Slimmed to Hydra + gym integration only: parse_overrides,
    apply_overrides, register_task, _run_hydra, hydra_task_config,
    resolve_task_config, plus _normalize_preset_name (which needs its
    own stacklevel walker keyed to hydra.py's __file__). Re-exports
    PresetCfg, preset, collect_presets etc. so existing
    `from isaaclab_tasks.utils.hydra import PresetCfg` keeps working.

  isaaclab_tasks.utils.preset_cli
    Becomes a backward-compat shim that re-exports from core, so
    existing `from isaaclab_tasks.utils.preset_cli import PresetCli`
    keeps working.

`from isaaclab_tasks.utils import PresetCfg`, `... import PresetCli`,
`... import setup_cli` continue to resolve to the same identical objects
as the new core paths -- env code touches nothing.
Fixes the BUG and CONCERNs raised by the codex review:

* Layer hygiene (BUG): isaaclab.utils.preset_cli no longer reaches into
  isaaclab_tasks. Replaced the lazy import inside _enumerate with an
  injectable task loader (register_task_loader). isaaclab_tasks/__init__.py
  registers the gym-backed loader at import time, so the dependency now
  flows the right direction (tasks -> core, never core -> tasks).

* Backward-compat re-export: hydra.py now also re-imports configclass so
  existing 'from isaaclab_tasks.utils.hydra import configclass' keeps
  working after the preset primitives moved to core.

* Decorator double-binding: PresetRegistry.name now rejects re-decorating
  the same class with a different canonical name (checks the class's own
  __dict__ so subclasses can still legitimately override an inherited
  parent name with their own decoration).

* Test coverage:
  - Tightened the cross-env drift lint to surface unexpected task-load
    failures via _DRIFT_LINT_EXPECTED_SKIPS allowlist instead of silently
    swallowing them.
  - Added test_decorator_rejects_same_class_under_different_name.
  - Added test_setup_cli_commit_true_rewrites_sys_argv +
    test_setup_cli_commit_false_returns_pieces_and_leaves_sys_argv,
    stubbing isaaclab.app to avoid Isaac Sim's stdin-reading kit_app init
    in pytest's captured-output mode.
  - Documented why the "no cross-kind name overlap" lint codex suggested
    is not enforced: the repo's existing convention reuses backend labels
    (newton_mjwarp, physx) on backend-tagged PresetCfgs across kinds
    (events, sensors, articulation), and the global broadcast propagating
    to all of them is the intended behavior.

120 tests passing.
Pulls every standalone helper into its associated class so the public
surface is one identifier per concern instead of a fan-out of free
functions:

* PresetRegistry (preset_meta) -- already absorbed all registry/decorator/
  lookup helpers in an earlier commit. Drop the module-level lookup aliases
  (canonical_name_for, all_canonical_names, class_for_canonical_name); the
  decorator alias preset_name stays because @preset_name("...") is the
  natural decorator spelling.

* PresetCfg (preset_cfg) -- adds classmethods fields, walk, collect,
  factory. Drop module-level _preset_fields, _walk_cfg, collect_presets;
  callers reach them via PresetCfg.fields/.walk/.collect. The DSL alias
  preset = PresetCfg.factory stays because preset(default=..., foo=...)
  reads naturally as a one-liner inside env code. Helpers must be
  classmethods (not staticmethods) because @configclass strips static-method
  descriptors during dataclass conversion -- noted in a comment so future
  contributors don't trip on it.

* PresetCli (preset_cli) -- absorbs register_task_loader,
  validate_preset_cfg, _kind_of_class, _kind_of_value, _vocab_for_kind,
  _format_inline_or_bullet, _ensure_backends_imported,
  _extract_task_from_argv, _enumerate, _format_unknown_canonical,
  _format_not_in_task, _validate_one. Module-level setup_cli =
  PresetCli.setup alias kept because the canonical script wiring is
  setup_cli(parser).

isaaclab_tasks/__init__.py uses PresetCli.register_task_loader. The
isaaclab_tasks/utils/preset_cli.py shim still re-exports the public
API surface (PresetCli, setup_cli, validate_preset_cfg, kind constants).

120 tests passing. Pre-commit clean.
Rebuilds the canonical-name registry around two ideas:

1. PresetKind is an Enum. Each member carries (label, legacy_aliases):
   the lowercase label drives the --{label} CLI flag and error text;
   the per-kind legacy_aliases dict deprecates names that mapped to
   canonical replacements of this kind. Adding a new CLI flag = adding
   one enum member -- the CLI layer auto-discovers it via
   PresetRegistry.all_kinds() and PresetRegistry.names_for_kind().

2. The decorator becomes @register(kind, name). Both bindings live at
   the same site, no inheritance-based or isinstance-based kind
   inference. The connection from "physx" to PresetKind.PHYSICS is now
   explicit at the decorator call, not deduced via class hierarchy.

PresetCli's argparse setup now iterates PresetCli._ordered_kinds()
(physics, renderer, then any other registered kinds, domain last) and
builds one parser flag per kind. Adding a new PresetKind member adds
its --{kind} flag automatically; PresetCli has no kind-specific code
paths or hardcoded constants.

The legacy alias map (newton -> newton_mjwarp, kamino -> newton_kamino)
moved from a private module-level dict in preset_cfg.py to
PresetKind.PHYSICS.legacy_aliases. Both deprecation channels --
hydra._normalize_preset_name (CLI side) and PresetCfg.__getattr__
(attribute side) -- now go through PresetKind.find_legacy() instead of
imported dict references.

7 backend cfg files updated: @register(PresetKind.PHYSICS, "physx") /
@register(PresetKind.RENDERER, "newton_renderer") / etc. The
isaaclab.utils package re-exports PresetKind and `register`; the old
preset_name decorator is gone (only 7 internal call sites used it,
all updated in this commit).

120 tests passing. Pre-commit clean.
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_name decorator 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 and from isaaclab_tasks.utils.hydra import PresetCfg still 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:

  1. The _BACKEND_MODULES force-import list is a maintenance liability — adding a new backend requires editing this list
  2. PresetCli._task_loader as a mutable class variable is a testing footgun (global state shared across test cases)
  3. 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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 ------------------------------------
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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``.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 _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"})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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.

hujc7 added 8 commits May 9, 2026 03:07
…stants

* preset_cli.py top docstring referenced preset_name (gone) and described
  kind classification as "derived from class inheritance" (no longer
  true after the @register(kind, name) refactor). Rewrote to describe
  the enum-driven kind taxonomy and PresetRegistry-backed vocabulary.

* preset_cli.py module-level KIND_PHYSICS/KIND_RENDERER/KIND_DOMAIN
  string constants were unused. Dropped them and the corresponding
  re-exports in the isaaclab_tasks shim.

* PresetRegistry: factored the MRO walk into a single
  ``_attr_via_mro`` static helper. ``name_of``, ``kind_of``, and
  ``kind_of_class`` all share it -- one place to change the lookup
  contract.

120 tests passing. Pre-commit clean.
* BUG: register() accepted re-decorating the same class with the same
  name but a *different* PresetKind, silently mutating _preset_kind. Now
  rejected with a "cannot re-register" error. Regression test added.

* CONCERN: PresetRegistry.all_kinds() now returns the full PresetKind
  enum, not just kinds with at least one registered class. Adding a new
  PresetKind member is sufficient to surface its --{label} flag in
  --help (with an empty vocabulary until a class is decorated). Matches
  the documented contract.

* NIT: PresetCli._vocab_for_kind() used to scan all_names() and re-classify
  each entry by walking MRO. Replaced with a direct call to
  PresetRegistry.names_for_kind(kind), which already partitions by kind.

* Docs/comments cleanup:
  - changelog fragment: switched the example from @preset_name("physx") to
    @register(PresetKind.PHYSICS, "physx") and added the "new kind = one
    enum member" line.
  - docs/source/features/hydra.rst: stopped describing kind as inferred
    from class inheritance; describes the @register decorator instead.
  - PresetCfg docstring + __getattr__ comment: reference
    isaaclab.utils.preset_meta.register and PresetKind.legacy_aliases.
  - PresetCli.apply() docstring: reference @register, not @preset_name.
  - test_preset_cli.py module docstring + drift-lint failure message:
    same.
  - PresetKind class: inline comment above PHYSICS explains that members
    are (label, legacy_aliases) tuples.

121 tests passing (added test_decorator_rejects_same_class_same_name_different_kind).
Pre-commit clean.

Deferred (lower-priority bot/codex notes):
* _BACKEND_MODULES is a hardcoded fallback for scripts that don't import
  isaaclab_tasks. Self-discovery via per-package __init__ imports is a
  follow-up.
* PresetCli._task_loader as a class variable is mutable global state; a
  test contextmanager (override_task_loader) could replace direct setattr.
* _user_stacklevel exists in both preset_cfg.py and hydra.py (each
  inspects its own module's __file__). Sharing a stacklevel-outside helper
  is a minor cleanup.
PresetCli now lives in source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py.
The CLI surface is task-aware end-to-end (gym task lookup, backend cfg
force-import, AppLauncher wiring), so hosting it in core required a
mutable class-var task-loader injection plus a hardcoded backend module
list -- both flagged in review. Tasks is its native habitat; the loader
injection goes away (parse_cfg is now a direct local import) and the
backend module list is in-layer rather than a layer violation.

Generic value-type primitives (PresetCfg, PresetKind, PresetRegistry)
remain in core so backend cfg classes can decorate themselves without
pulling in tasks.

Restored bare-string KIND_PHYSICS / KIND_RENDERER / KIND_DOMAIN aliases
on the new module so legacy `from isaaclab_tasks.utils.preset_cli import
KIND_PHYSICS` callers keep getting the string they did pre-refactor.

Other fixes:
- add_args is now idempotent on the same parser (second call no-ops);
  guards the notebook / multi-step setup_cli path.
- Typed flag values (--physics newton) now route through
  PresetKind.normalize_name before validation, so legacy aliases yield
  the same FutureWarning + canonical replacement that the override path
  already produced.

Tests: 125 passed (added shim-import, idempotency, and typed-flag
legacy-alias regressions).
Three small cleanups, no behavior change:

- Drop unused private re-export aliases from hydra.py: _setattr,
  _parse_val, _known_preset_names, _format_unknown_presets_error,
  _normalize_preset_name, _pick_alternative, _preset_fields, _walk_cfg.
  None had external callers; they were leftover scaffolding from the
  PresetCli core/tasks split. register_task now calls
  PresetCli/PresetKind directly. The two test sites that did import
  these privates now go through PresetCli/PresetCfg.

- Delete dead _LITERAL_MAP from hydra.py (the live copy lives on
  PresetCli where _parse_val moved).

- Nest _PresetAwareHelpAction inside PresetCli as PresetCli._HelpAction.
  Per AGENTS.md "prefer nested classes when self-contained" -- the
  class only matters as the replacement installed by
  _install_help_extension and has no public identity.

hydra.py drops from ~252 lines to ~199; net diff -36 lines across the
preset surface. Tests: 125 passed.
Address adversarial review on preset_cli, four changes in one pass:

1. BUG fix: parser-level idempotency. The previous guard
   (``self._added_args_for is parser``) was instance-local; ``setup_cli``
   builds a fresh PresetCli per call, so calling it twice on the same
   parser used to add ``--physics``/``--renderer`` twice and crash.
   Stamp the parser object (``_isaaclab_preset_args_added`` and
   ``_isaaclab_preset_help_installed``) so any instance respects what
   was already wired. Tests cover same-instance and cross-instance.

2. PresetOverrides dataclass replaces the 4-tuple handshake between
   ``parse_overrides``, ``_run_hydra``, and ``apply_overrides``. The
   public signature is now
   ``apply_overrides(env_cfg, agent_cfg, hydra_cfg, overrides, presets)``
   instead of six positional list arguments. Test sites and
   ``env_test_utils`` / ``rendering_test_utils`` updated to construct
   PresetOverrides explicitly.

3. ``_requests_from_args`` helper consolidates the duplicated
   "walk every kind, pluck off the namespace, normalize aliases" loop
   that lived in both ``collect_selected`` and ``apply``. Both now
   call the shared method and reduce to a one-liner each.

4. Centralize legacy alias normalization. Aliases were normalized in
   four places (typed flag apply path, ``parse_overrides`` global,
   ``parse_overrides`` path-selection, ``apply_overrides`` re-pass),
   so a single ``presets=newton`` token emitted two FutureWarning
   events. ``parse_overrides`` is now the single normalization point;
   ``apply_overrides`` trusts its input. Added a regression test that
   asserts exactly one deprecation warning per legacy token.

Tests: 125 passed.
The PR was carrying re-export shims that pretended to be back-compat
with develop, but none of these symbols ever shipped: KIND_PHYSICS
string aliases, validate_preset_cfg module alias, hydra.py re-exports
of PresetCfg / preset / configclass / collect_presets / resolve_presets
/ parse_overrides / apply_overrides, and the matching shim test that
codex flagged as a BUG. There is nothing to be backward-compatible with.

What stays:
- isaaclab_tasks.utils still re-exports PresetCfg + preset for env-cfg
  authors (real public API: 30+ env configs import via this path).
- setup_cli, hydra_task_config, resolve_task_config: actual public API.
- PresetCli, PresetOverrides: the CLI surface.

What goes:
- KIND_PHYSICS / KIND_RENDERER / KIND_DOMAIN module-level string aliases.
  Use PresetKind.PHYSICS.value if you really want the string.
- validate_preset_cfg module-level alias. Use PresetCli.validate_preset_cfg.
- hydra.py re-exports of PresetCfg / preset / collect_presets /
  resolve_presets / parse_overrides / apply_overrides. Reach those through
  their canonical class methods (PresetCfg.collect, PresetCli.parse_overrides).
- parse_cfg.py "resolve_presets = PresetCfg.resolve" alias.
- test files' module-level aliases of the same.
- The two shim-import tests added in the previous commit -- they were
  guarding nothing real.

Backend force-load also collapsed: the _BACKEND_MODULES tuple and the
PresetCli._ensure_backends_imported helper are gone. The seven
contextlib.suppress(ImportError) imports run unconditionally at the top
of preset_cli.py. Every script that uses setup_cli reaches add_args
anyway, so lazy import bought nothing.

Tests: 123 passed.
The two-file split was a historical artifact of staged refactors --
preset_cfg.PresetCfg.__getattr__ already calls
preset_meta.PresetKind.normalize_name, and preset_meta's docstrings
already reference PresetCfg. With the public surface stable they should
sit next to each other so a reader looking for "the preset stuff" lands
in one place.

Module is named ``presets.py`` (plural) to avoid colliding with the
``preset()`` factory function the package re-exports -- ``preset.py``
would shadow the function name in ``isaaclab.utils.preset`` lookups.

Files removed:
  source/isaaclab/isaaclab/utils/preset_meta.py
  source/isaaclab/isaaclab/utils/preset_cfg.py

File added:
  source/isaaclab/isaaclab/utils/presets.py  (570 lines, both halves)

All call sites and Sphinx cross-refs updated to point at the new module.
Tests: 123 passed.
The @register decorator on each backend cfg class is the only place
canonical names are declared. Anything else listing those names was a
duplicate. Removed:

- source/isaaclab_tasks/.../preset_cli.py: the seven-entry force-import
  loop at module-top, and the inline canonical-vocabulary in --physics /
  --renderer help text.

How the registry stays populated without the loop:
- ``apply()`` calls ``_enumerate(task_name)`` which loads the task's env
  config; the env config imports the backends it uses; @register fires.
- ``--task=X --help`` follows the same path via ``describe_task``.

Tradeoff: bare ``--help`` (no --task) used to inline the global
canonical vocabulary, which required the registry pre-populated. It
now prints "use --task=<task> --help to list valid names". Honest:
without an env config there is no source for that list other than a
duplicate one, which is what we removed.

Tests stub ``_enumerate`` to bypass real env loading, so the test file
now imports the backend cfg modules explicitly at the top so its
assertions still see the canonical names. Production code does not need
the explicit imports.

Tests: 123 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant