Source code for protomotions.utils.config_utils

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# =============================================================================
# General Config Override Utilities
# =============================================================================

from __future__ import annotations

import logging
import sys
import types
import typing
from dataclasses import MISSING as _MISSING
from dataclasses import fields, is_dataclass, replace
from enum import Enum
from typing import Any, Collection, Dict, Mapping, Optional, Union

# ``X | None`` (PEP 604, Python 3.10+) evaluates to ``types.UnionType``, a
# different origin from ``typing.Union``; the two spell the same thing. On
# 3.8 and 3.9 there is no ``types.UnionType`` and the tuple holds ``Union`` twice.
_UNION_ORIGINS = (Union, getattr(types, "UnionType", Union))


[docs] def is_union(ann) -> bool: """Whether a resolved annotation is a union in either spelling.""" return typing.get_origin(ann) in _UNION_ORIGINS
[docs] def declaring_module(cls, name: str) -> str: """The module of the class in ``cls``'s MRO that declares field ``name``. A field's annotation string (``from __future__ import annotations``) names types imported where the field is DECLARED; a subclass in another module inherits the field without those imports, so the string is evaluated against the declaring class's module, as ``typing.get_type_hints`` does. Falls back to ``cls.__module__`` for a name no class in the MRO annotates. """ for klass in cls.__mro__: if name in vars(klass).get("__annotations__", {}): return klass.__module__ return cls.__module__
log = logging.getLogger(__name__) ROOTS_SENTENCE = "robot, simulator, terrain, scene_lib, motion_lib, env, agent"
[docs] def resolve_field_annotation(cls, name: str): """The resolved annotation of ``cls.name``, from that field alone; raises TypeError naming the field when the annotation string cannot be evaluated. The one per-field resolver shared by the B4 saved form (``config_schema``) and the B3 override funnel. Resolving the whole class (``get_type_hints``) would fail whenever any OTHER field carries a name that does not resolve; resolving per field keeps one unrelated sibling from breaking every field being saved or overridden. The field's type must be importable at the level of the module that DECLARES the field (an inherited field resolves in its base class's module, as ``get_type_hints`` does; config.py imports MdpComponent and TerminationRule there) and spelled so this Python can evaluate it (PEP 604 unions and builtin generics need 3.10 / 3.9); an annotation that does not resolve is a real error, not a case to silence by returning ``Any`` (which would drop the None-for-non-Optional refusal at save). """ field = next((f for f in fields(cls) if f.name == name), None) if field is None: return Any ann = field.type if isinstance(ann, str): module = declaring_module(cls, name) try: ann = eval( # noqa: S307 - evaluating a dataclass field annotation ann, vars(sys.modules[module]) ) except Exception as error: raise TypeError( f"annotation {ann!r} of {cls.__name__}.{name} does not resolve: its " f"type is not importable at {module} module level (only under " "TYPE_CHECKING), or the annotation uses syntax this Python cannot " "evaluate (PEP 604 unions and builtin generics need 3.10 / 3.9)" ) from error return ann
def _override_annotation(cls, name: str, path: str): """``resolve_field_annotation`` with the override funnel's error context.""" try: return resolve_field_annotation(cls, name) except TypeError as error: raise TypeError(f"cannot type override {path!r}: {error}") from error def _require_field(cls, name: str, path: str) -> None: """A dataclass step must name a declared ``init=True`` field, not a property.""" by_name = {f.name: f for f in fields(cls)} field = by_name.get(name) if field is not None and field.init: return derived = (field is not None and not field.init) or isinstance( getattr(cls, name, None), property ) suffix = ( " (it is derived; override the field it is derived from)" if derived else "" ) raise ValueError( f"{path!r}: {name!r} is not a declared field of {cls.__name__}{suffix}" ) def _coerce_enum(value, enum_cls, path: str): for member in enum_cls: if value == member.name or value == member.value: return member values = [m.value for m in enum_cls] raise ValueError( f"invalid value {value!r} for {enum_cls.__name__} at {path!r}; valid: {values}" ) def _mismatch(path, value, ann, cls, name) -> ValueError: return ValueError( f"override {path}={value!r}: expected {ann} " f"({cls.__name__}.{name}), got {type(value).__name__}" )
[docs] def coerce(value, ann, path, cls, name): """Coerce a parsed literal to a field's declared annotation (never its runtime value).""" if ann is Any or ann is None: return value if ann is type(None): if value is None: return None raise _mismatch(path, value, ann, cls, name) if ann is bool: if isinstance(value, bool): return value raise _mismatch(path, value, ann, cls, name) if ann is int: if isinstance(value, bool) or not isinstance(value, int): raise _mismatch(path, value, ann, cls, name) return value if ann is float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise _mismatch(path, value, ann, cls, name) return float(value) if ann is str: if isinstance(value, str): return value raise _mismatch(path, value, ann, cls, name) if isinstance(ann, type) and issubclass(ann, Enum): return _coerce_enum(value, ann, path) origin = typing.get_origin(ann) args = typing.get_args(ann) if is_union(ann): if value is None and type(None) in args: return None members = [m for m in args if m is not type(None)] if isinstance(value, dict): for member in members: if is_dataclass(member): return member(**value) # constructor errors surface for member in members: try: return coerce(value, member, path, cls, name) except (ValueError, TypeError): continue raise ValueError( f"override {path}={value!r}: no member of {ann} " f"({cls.__name__}.{name}) accepts it; members are {list(members)}" ) if origin in (list, tuple): if not isinstance(value, (list, tuple)): raise _mismatch(path, value, ann, cls, name) if origin is tuple and len(args) == 2 and args[1] is Ellipsis: args = (args[0],) * len(value) if args and origin is list: args = (args[0],) * len(value) items = [coerce(v, a, path, cls, name) for v, a in zip(value, args or value)] return items if origin is list else tuple(items) if origin is dict: return value if origin is typing.Literal: if value in args: return value raise ValueError( f"override {path}={value!r}: not one of {list(args)} ({cls.__name__}.{name})" ) if is_dataclass(ann) and isinstance(value, dict): return ann(**value) if ann is dict or ann is list or ann is tuple: return value raise _mismatch(path, value, ann, cls, name)
def _descend(obj, name: str, path: str): """One step into ``obj`` by ``name``; raises if the step is not legal.""" if is_dataclass(obj) and not isinstance(obj, type): _require_field(type(obj), name, path) return getattr(obj, name) if isinstance(obj, dict): if name not in obj: raise ValueError( f"{path!r}: {name!r} is not a key of the dict at this step" ) return obj[name] if not hasattr(obj, name): raise ValueError(f"{path!r}: {type(obj).__name__} has no attribute {name!r}") return getattr(obj, name) def _construct_optional(obj, name: str, path: str): """A ``None`` on the way is built from its annotation when it has no required fields.""" if not (is_dataclass(obj) and not isinstance(obj, type)): return None ann = _override_annotation(type(obj), name, path) members = typing.get_args(ann) if is_union(ann) else (ann,) target = next((m for m in members if isinstance(m, type) and is_dataclass(m)), None) if target is None: return None required = [ f.name for f in fields(target) if f.default is _MISSING and f.default_factory is _MISSING ] if required: raise ValueError( f"{path!r} crosses {name} which is None and {target.__name__} has " f"required fields; set {name} first" ) return target() def _assign(obj, name: str, value): """Write ``value`` at ``obj.name``, rebuilding a frozen dataclass in place.""" if isinstance(obj, dict): obj[name] = value return obj if is_dataclass(obj) and obj.__dataclass_params__.frozen: return replace(obj, **{name: value}) setattr(obj, name, value) return obj def _set_path(obj, parts, path, value): """Set ``value`` at ``parts`` under ``obj``; returns (new ``obj``, coerced value).""" name = parts[0] if len(parts) == 1: if is_dataclass(obj) and not isinstance(obj, type): _require_field(type(obj), name, path) ann = _override_annotation(type(obj), name, path) coerced = coerce(value, ann, path, type(obj), name) elif isinstance(obj, dict): if name not in obj: raise ValueError( f"{path!r}: {name!r} is not a key of the dict at this step" ) coerced = value else: if not hasattr(obj, name): raise ValueError( f"{path!r}: {type(obj).__name__} has no attribute {name!r}" ) coerced = value return _assign(obj, name, coerced), coerced child = _descend(obj, name, path) if child is None: child = _construct_optional(obj, name, path) new_child, coerced = _set_path(child, parts[1:], path, value) return _assign(obj, name, new_child), coerced def _resolve_root(configs, root: str, path: str): from protomotions.utils.experiment_configs import ExperimentConfigs if root not in ExperimentConfigs.ROOTS: raise ValueError( f"unknown override root {root!r} in {path!r}; roots are {ROOTS_SENTENCE}" ) obj = getattr(configs, root) if root == "agent" and obj is None: raise ValueError( f"cannot override {path!r}: this experiment has no agent config" ) return obj def _apply_one(configs, path: str, value): root, *rest = path.split(".") obj = _resolve_root(configs, root, path) if not rest: raise ValueError(f"override {path!r} names only a root; expected root.field") if root == "robot": # Robot writes re-derive __post_init__ facts through update_fields, which # also refuses a derived field (D's anchor property). The walker refuses a # property first, so anchor_body_index=... fails with the generic message. first = rest[0] _require_field(type(obj), first, path) if len(rest) == 1: ann = _override_annotation(type(obj), first, path) coerced = coerce(value, ann, path, type(obj), first) else: child = getattr(obj, first) if child is None: child = _construct_optional(obj, first, path) child, coerced = _set_path(child, rest[1:], path, value) obj.update_fields(**{first: child if len(rest) > 1 else coerced}) return coerced new_obj, coerced = _set_path(obj, rest, path, value) if new_obj is not obj: setattr(configs, root, new_obj) return coerced
[docs] def apply_overrides( configs, overrides: Mapping[str, Any], roots: Optional[Collection[str]] = None, ) -> Dict[str, Any]: """Apply ``{root.path: value}`` overrides to an ExperimentConfigs in place. The leaf is typed by its dataclass annotation, never by the runtime value, so a typo or a wrong type fails at parse time naming the field. ``roots`` limits which roots are applied (``None`` = all), which lets the build funnel apply ``robot`` and ``simulator`` early and the rest after the build. Returns the coerced ``{path: value}`` that landed, for the undo check. """ from protomotions.utils.experiment_configs import ExperimentConfigs applied: Dict[str, Any] = {} for path, value in overrides.items(): root = path.split(".", 1)[0] # Refuse an unknown root even when this pass filters it out, so a typo'd # root is not silently dropped by the early/late split in build_configs. if root not in ExperimentConfigs.ROOTS: raise ValueError( f"unknown override root {root!r} in {path!r}; roots are {ROOTS_SENTENCE}" ) if roots is not None and root not in roots: continue applied[path] = _apply_one(configs, path, value) if applied: log.info(f"Applied {len(applied)} config override(s): {sorted(applied)}") return applied
[docs] def read_override(configs, path: str): """Read the current value at a ``root.path`` override path on an ExperimentConfigs. Walks the way the funnel does (a dict step by key, a dataclass or any other object by attribute). The inference derivation and the build-time undo check read back through this to see whether a builder step changed an early override. """ root, *rest = path.split(".") obj = _resolve_root(configs, root, path) for part in rest: obj = obj[part] if isinstance(obj, dict) else getattr(obj, part) return obj
[docs] def parse_cli_overrides(override_strings: list) -> Dict[str, Any]: """ Parse command-line override strings into a dictionary. Supports the format: "key=value" where value can be: - Numbers: "env.max_episode_length=1000" - Floats: "agent.learning_rate=1e-5" - Booleans: "env.enable_terrain=True" - Strings: "env.terrain.type=flat" - None: "env.early_termination=None" Args: override_strings: List of "key=value" strings Returns: Dictionary of parsed overrides Raises: ValueError: If any override string is not in "key=value" format or key is empty. Example: parse_cli_overrides(["env.max_episode_length=1000", "simulator.num_envs=4096"]) # Returns: {"env.max_episode_length": 1000, "simulator.num_envs": 4096} """ overrides = {} for override_str in override_strings: if "=" not in override_str: raise ValueError( f"Invalid override format (required key=value): {override_str!r}. " "Use e.g. robot.asset.asset_file_name=mjcf/g1_holo_compat.xml" ) key, value_str = override_str.split("=", 1) key = key.strip() value_str = value_str.strip() if not key: raise ValueError( f"Invalid override: key is empty in {override_str!r}. " "Use format key=value (e.g. robot.asset.asset_file_name=path.xml)." ) try: import ast value = ast.literal_eval(value_str) except (ValueError, SyntaxError): value = value_str overrides[key] = value return overrides