Skip to content

Keep isaaclab.utils.configclass bound to the decorator - #7646

Open
pascal-roth wants to merge 4 commits into
isaac-sim:developfrom
pascal-roth:fix/utils-configclass-lazy-shadowing
Open

Keep isaaclab.utils.configclass bound to the decorator#7646
pascal-roth wants to merge 4 commits into
isaac-sim:developfrom
pascal-roth:fix/utils-configclass-lazy-shadowing

Conversation

@pascal-roth

@pascal-roth pascal-roth commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Description

isaaclab/utils/__init__.py attaches its exports lazily from __init__.pyi. One of those exports,
configclass, has the same name as the sub-module that defines it, isaaclab.utils.configclass.
Whenever that sub-module is imported, the import machinery binds the module object onto
isaaclab.utils, which shadows the lazily attached decorator. From that point on:

from isaaclab.utils import configclass  # -> <module 'isaaclab.utils.configclass'>

@configclass
class MyCfg:
    value: int = 1
# TypeError: 'module' object is not callable

Which object the name resolves to depends only on which of the two was imported first, so downstream
code breaks without ever importing the sub-module itself. Importing isaaclab_rl, isaaclab_newton,
isaaclab_contrib or isaaclab_teleop is enough, since they all use
from isaaclab.utils.configclass import configclass internally:

from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg
from isaaclab.utils import configclass  # now a module

@configclass
class MyAgentCfg:  # TypeError: 'module' object is not callable
    ...

configclass is a documented export of isaaclab.utilsdocs/source/api/lab/isaaclab.utils.rst
lists it under the module's Functions rubric — and this is the import form Isaac Lab 1.x/2.x code
uses, so downstream projects hit this while porting to 3.0 and have to rewrite every configclass
import. lazy_loader guards against this collision, but only when the attribute happens to be
resolved before the sub-module is imported, so the mitigation is order-dependent.

The mirror image is broken today too: resolving the decorator first makes lazy_loader write it
into the package __dict__, after which the sub-module is unreachable as an attribute of
isaaclab.utilsimport isaaclab.utils.configclass as m binds the function, and
isaaclab.utils.configclass._field_module_dir raises AttributeError. One attribute slot cannot
hold two objects, so whichever import runs first wins and the other form breaks.

This PR makes them one object. isaaclab.utils.configclass gets a ModuleType subclass that
forwards __call__ to the decorator, and isaaclab.utils.__getattr__ always hands out that
sub-module rather than letting lazy_loader cache the bare function. The attribute is then the same
object regardless of import order, and it is both a module and callable, so the decorator, aliased
imports, dotted attribute access, from isaaclab.utils.configclass import ...,
importlib.import_module, mock.patch and string_to_callable all work in either order. Nothing
that works on develop stops working, so no migration is required.

configclass is currently the only name in the repository where a .pyi stub export collides with a
sibling sub-module of the same name, so nothing else changes behaviour.

Fixes # (issue)

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Release backport

  • Backport this pull request to the active release branch after it merges into develop

Checklist

  • I have read and understood the contribution guidelines
  • I have run the pre-commit checks with ./isaaclab.sh --format
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • I have added a changelog fragment under source/<pkg>/changelog.d/ for every touched package
  • I have added my name to the CONTRIBUTORS.md or my name already exists there

The sub-module isaaclab.utils.configclass and the decorator it defines share a
name. Importing the sub-module makes the import machinery bind the module object
onto isaaclab.utils, shadowing the attribute that lazy_loader attaches from the
.pyi stub. From that point on `from isaaclab.utils import configclass` returns a
module and `@configclass` fails with "TypeError: 'module' object is not
callable". The outcome depends only on which of the two was imported first, so
downstream code breaks without ever touching the sub-module: importing
isaaclab_rl, isaaclab_newton, isaaclab_contrib or isaaclab_teleop is enough,
since they all import the sub-module directly.

Give isaaclab.utils a module type that skips exactly that assignment, so the
attribute exported by the stub wins regardless of import order. The sub-module
stays importable through `from isaaclab.utils.configclass import ...`.
@pascal-roth
pascal-roth requested a review from a team September 8, 2026 16:24
@github-actions github-actions Bot added bug Something isn't working isaac-lab Related to Isaac Lab team labels Sep 8, 2026
@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

The PR should not merge until aliased direct imports of isaaclab.utils.configclass continue to return the module rather than the decorator.

Findings

  1. P1 Aliased submodule import breaks

Summary

  • Introduces a package ModuleType subclass that rejects conflicting child-module assignments.
  • Verifies the documented package-level decorator import after loading the submodule.
  • Preserves the decorator as intended, but breaks aliased direct imports of the submodule.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["import isaaclab.utils.configclass as cfg"] --> B["Import machinery loads submodule"]
  B --> C["Assign submodule to isaaclab.utils.configclass"]
  C --> D["_LazyAttributePackage.__setattr__ discards assignment"]
  D --> E["Alias resolves parent attribute"]
  E --> F["cfg receives decorator instead of module"]
  F --> G["cfg.checked_apply fails"]
Loading

Comment on lines +34 to +35
if name in __all__ and isinstance(value, types.ModuleType) and value.__name__ == f"{__name__}.{name}":
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Aliased submodule import breaks

With import isaaclab.utils.configclass as configclass_module, Python resolves the alias through isaaclab.utils.configclass. This guard discards the submodule assignment, so the alias receives the decorator instead of the module. Callers then fail when accessing module members such as configclass_module.checked_apply. The new test imports the submodule only for its side effect, so it does not cover this valid import form.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Isaac Lab Review Bot

The fix makes the documented from isaaclab.utils import configclass import order-independent, but suppressing the child-module binding also changes established dotted-import and parent-attribute behavior for isaaclab.utils.configclass. This compatibility impact should be preserved or explicitly documented with migration guidance.

  • Design and architecture: The custom module type narrowly guards same-named stub exports, but it intentionally breaks the normal package invariant that an imported child module is available as an attribute of its parent. That tradeoff affects consumers beyond the documented decorator import and needs maintainer action before merge.
  • API: The decorator import and from isaaclab.utils.configclass import ... remain functional. However, import isaaclab.utils.configclass as m can bind the decorator rather than the module, while dotted access and string-based resolution through isaaclab.utils.configclass no longer reach the child module. Preserve module-valued access or document the changed behavior and migration path in the changelog.
  • Implementation: The guard is narrowly scoped by __all__, module type, and expected child-module name, and the subprocess test validates the targeted import-order regression. It does not cover the newly changed dotted-import and parent-attribute access paths identified in finding 0.

Minor fixes needed. Posted 1 actionable finding inline.

Automated review; human maintainers own approval decisions.

"""

def __setattr__(self, name: str, value: Any) -> None:
if name in __all__ and isinstance(value, types.ModuleType) and value.__name__ == f"{__name__}.{name}":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Warning · Api — Sub-module unreachable via parent attribute path

Suppressing this assignment makes isaaclab.utils.configclass resolve to the decorator forever. import isaaclab.utils.configclass as m silently binds the function (IMPORT_FROM prefers getattr and only falls back to sys.modules on AttributeError), and dotted access such as isaaclab.utils.configclass._field_module_dir or mock.patch("isaaclab.utils.configclass.X") now raises AttributeError. Either preserve module-valued access (e.g. make the sub-module itself callable) or record this changed behavior with migration guidance in the changelog fragment.

Review pointed out that suppressing the child-module binding kept the decorator
reachable but cost the other half: `import isaaclab.utils.configclass as m` and
`isaaclab.utils.configclass._field_module_dir` resolved to the decorator and
raised AttributeError.

Both halves cannot live in one attribute as long as they are two objects, so
make them one: give the sub-module a module type that forwards __call__ to the
decorator, and have isaaclab.utils always hand out that sub-module. Every import
form now works in either order, which was not true before this branch either -
resolving the decorator first already made the sub-module unreachable through
the parent package.
@pascal-roth

Copy link
Copy Markdown
Collaborator Author

Thanks — both findings are correct, and I've pushed 8b48c86 to address them.

The finding reproduces. With the first version, on the develop tree:

import form sub-module imported first decorator resolved first
import isaaclab.utils.configclass as m; m.checked_apply ❌ AttributeError ❌ AttributeError
isaaclab.utils.configclass._field_module_dir ❌ AttributeError ❌ AttributeError
@configclass from isaaclab.utils

But module-valued access was already order-dependent before this PR. Same matrix on unpatched develop:

import form sub-module imported first decorator resolved first
import isaaclab.utils.configclass as m; m.checked_apply ❌ AttributeError
isaaclab.utils.configclass._field_module_dir ❌ AttributeError
@configclass from isaaclab.utils ❌ TypeError: 'module' object is not callable

lazy_loader writes the resolved attribute straight into the package __dict__ when it shares a
name with its defining module, so resolving the decorator first already makes the sub-module
unreachable as an attribute of isaaclab.utilsmock.patch("isaaclab.utils.configclass.X") only
survives that because pkgutil.resolve_name prefers sys.modules. So there is no baseline in which
both halves work; the name has one attribute slot and two candidate objects.

Fix: make them one object, as @isaaclab-review-bot suggested. isaaclab.utils.configclass now
gets a ModuleType subclass that forwards __call__ to the decorator, and isaaclab.utils.__getattr__
always hands out that sub-module instead of letting lazy_loader cache the bare function. The
attribute is therefore the same object either way, and it is both a module and callable:

import form sub-module imported first decorator resolved first
import isaaclab.utils.configclass as m; m.checked_apply
isaaclab.utils.configclass._field_module_dir
@configclass from isaaclab.utils
from isaaclab.utils.configclass import configclass, checked_apply
importlib.import_module("isaaclab.utils.configclass")
mock.patch("isaaclab.utils.configclass._field_module_dir")
string_to_callable("isaaclab.utils.configclass:checked_apply")

No behaviour is removed relative to develop, so the changelog fragment records the fix without
migration guidance. The regression test is now parametrised over both import orders and covers the
aliased and dotted forms as well; it fails on develop for both parameters and passes here.

One documented consequence worth a maintainer's eye: from isaaclab.utils import configclass now
always yields the module rather than the raw function, so inspect.isfunction() on it is False.
Calling it, from isaaclab.utils.configclass import configclass, and the Sphinx build are unaffected
docs/source/api/lab/isaaclab.utils.rst already does automodule:: isaaclab.utils.configclass, so
the docs build resolves that name to the module today. Happy to switch to the narrower "decorator
wins, changed behaviour documented in the changelog" variant if you prefer that tradeoff.

@pascal-roth pascal-roth self-assigned this Sep 8, 2026
@pascal-roth

Copy link
Copy Markdown
Collaborator Author

Some history that may be useful for triage: this is the second time the collision has been paid for.

#5647 ("Fix lazy import for configclass and provide upper bound for python", May 2026) rewrote 436
imports across 439 files plus 8 documentation pages — including
docs/source/migration/migrating_to_isaaclab_3-0.rst and both setup/walkthrough pages — from
from isaaclab.utils import configclass to from isaaclab.utils.configclass import configclass. It
did not touch source/isaaclab/isaaclab/utils/__init__.py, and it filed .skip changelog fragments
in all 11 affected packages, so nothing about it reached the release notes or the migration guide.

The result is that the collision is still live for anyone outside the repository. release/2.2.0 has
225 .py occurrences of the package-level form, it is still what the API reference advertises
(docs/source/api/lab/isaaclab.utils.rst lists configclass under isaaclab.utils's Functions
rubric), and there is no migration entry saying it has to change. A downstream project porting to 3.0
gets only TypeError: 'module' object is not callable, from a line that never mentions the
sub-module — which is how I ran into it.

This PR fixes the collision at the source instead, so neither the internal rewrite nor a downstream
one is needed. Happy to also drop the now-unnecessary internal from isaaclab.utils.configclass import configclass rewrite in a follow-up if you want the tree back on the shorter form, but I left it out
here to keep the diff reviewable.

@kellyguo11

Copy link
Copy Markdown
Contributor

run-ci

@isaaclab-bot isaaclab-bot Bot added ci:run-docker Trigger the on-demand Docker and GPU CI workflow and removed ci:run-docker Trigger the on-demand Docker and GPU CI workflow labels Sep 8, 2026
@ooctipus

ooctipus commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

nice!! @pascal-roth do you mind also update all from isaaclab.utils.configclass import configclass instances  to from isaaclab.utils import configclass

PR isaac-sim#5647 rewrote 436 imports across the repository to
`from isaaclab.utils.configclass import configclass` to work around the name
collision between the sub-module and the decorator it defines. The collision is
fixed at the source now, so the workaround is no longer needed: put the tree back
on the shorter, documented `from isaaclab.utils import configclass`, including
the eight documentation pages and the skills examples that PR rewrote.

Mechanical change - the only edits are the import line itself and the import
sorting that follows from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pascal-roth

Copy link
Copy Markdown
Collaborator Author

Pushed 9951152, which does the follow-up I offered above: the tree is back on
from isaaclab.utils import configclass everywhere, undoing the workaround #5647 had to apply.

  • 532 files, 526 import sites plus the import sorting that follows from them. Nothing else changed.
  • Includes the 8 documentation pages Fix lazy import for configclass and provide upper bound for python #5647 rewrote (migrating_to_isaaclab_3-0.rst, both
    setup/walkthrough pages, features/hydra.rst, refs/contributing.rst, ...), the two
    skills/user/* example sets, and the 5 tools/template/templates/ scaffolds, so generated projects
    start on the documented form again.
  • source/isaaclab/test/utils/test_configclass.py keeps importing the sub-module directly on purpose —
    that is what the regression test exercises.
  • .skip changelog fragments for the 11 packages this touches, matching what Fix lazy import for configclass and provide upper bound for python #5647 did.

Verification: ruff check clean across the repo; tools/changelog/cli.py check develop passes;
source/isaaclab/test/utils (configclass, dict, string, timer, modifiers, noise, buffers, logger,
math, assets) all pass; and scripts/environments/list_envs.py runs to completion against the
rewritten tree, listing all 137 registered environments — that imports the whole isaaclab_tasks
config surface plus the backend packages, so the rewritten imports resolve at runtime.

This commit is independent of the fix itself. If you would rather land the fix alone and do the
cleanup separately, dropping it is just git reset --hard 8b48c86 — the first two commits stand on
their own.

@github-actions github-actions Bot added documentation Improvements or additions to documentation isaac-mimic Related to Isaac Mimic team infrastructure labels Sep 9, 2026
@pascal-roth

Copy link
Copy Markdown
Collaborator Author

run-ci

@isaaclab-bot isaaclab-bot Bot added ci:run-docker Trigger the on-demand Docker and GPU CI workflow and removed ci:run-docker Trigger the on-demand Docker and GPU CI workflow labels Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation infrastructure isaac-lab Related to Isaac Lab team isaac-mimic Related to Isaac Mimic team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants