diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7556706cd2f0..0fcef1818780 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -101,7 +101,7 @@ env: ISAACSIM_BASE_IMAGE: 'nvcr.io/nvidian/isaac-sim' # ${{ vars.ISAACSIM_BASE_IMAGE || 'nvcr.io/nvidia/isaac-sim' }} # Pinned to the Apr 8 nightly digest that last passed CI, while latest-develop is broken. # TODO(AntoineRichard): Revert to 'latest-develop' once the nightly is fixed. - ISAACSIM_BASE_VERSION: 'latest-develop@sha256:f085fb5b6899511bb19abdf18121bfc469901334aefb029d0def56d4fef79c58' # ${{ vars.ISAACSIM_BASE_VERSION || '6.0.0' }} + ISAACSIM_BASE_VERSION: 'latest-develop' # ${{ vars.ISAACSIM_BASE_VERSION || '6.0.0' }} DOCKER_IMAGE_TAG: isaac-lab-dev:${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}-${{ github.sha }} # To run quarantined tests, create a GitHub repo variable named # RUN_QUARANTINED_TESTS and set it to 'true'. The test-quarantined diff --git a/.github/workflows/postmerge-ci.yml b/.github/workflows/postmerge-ci.yml index 73ec4ea5c124..0b677da9fef4 100644 --- a/.github/workflows/postmerge-ci.yml +++ b/.github/workflows/postmerge-ci.yml @@ -25,7 +25,7 @@ env: NGC_API_KEY: ${{ secrets.NGC_API_KEY }} # On merge to main defaults will point to "nvcr.io/nvidia/isaac-sim:6.0.0" ISAACSIM_BASE_IMAGE: 'nvcr.io/nvidian/isaac-sim' # ${{ vars.ISAACSIM_BASE_IMAGE || 'nvcr.io/nvidia/isaac-sim' }} - ISAACSIM_BASE_VERSIONS_STRING: 'latest-develop@sha256:f085fb5b6899511bb19abdf18121bfc469901334aefb029d0def56d4fef79c58' # Pinned to the Apr 8 nightly digest that last passed CI, while latest-develop is broken. TODO: Revert to 'latest-develop' once the nightly is fixed. # ${{ vars.ISAACSIM_BASE_VERSIONS_STRING || '6.0.0' }} + ISAACSIM_BASE_VERSIONS_STRING: 'latest-develop' # ${{ vars.ISAACSIM_BASE_VERSIONS_STRING || '6.0.0' }} ISAACLAB_IMAGE_NAME: ${{ vars.ISAACLAB_IMAGE_NAME || 'isaac-lab-base' }} jobs: diff --git a/apps/isaaclab.python.headless.kit b/apps/isaaclab.python.headless.kit index 1d28f7b9018c..12b9e14946d0 100644 --- a/apps/isaaclab.python.headless.kit +++ b/apps/isaaclab.python.headless.kit @@ -26,7 +26,6 @@ log.outputStreamLevel = "Warn" "omni.physx" = {} "omni.physx.tensors" = {} "omni.physx.fabric" = {} -"omni.warp.core" = {} "usdrt.scenegraph" = {} "omni.kit.telemetry" = {} "omni.kit.loop" = {} @@ -107,7 +106,7 @@ isaac.startup.ros_bridge_extension = "" metricsAssembler.changeListenerEnabled = false # explicitly disable omni.kit.pip_archive to prevent conflicting dependencies -app.extensions.excluded = ["omni.kit.pip_archive"] +app.extensions.excluded = ["omni.kit.pip_archive", "omni.isaac.ml_archive", "isaacsim.pip.newton", "omni.warp.core"] # Extensions ############################### diff --git a/apps/isaaclab.python.headless.rendering.kit b/apps/isaaclab.python.headless.rendering.kit index 1fb9773bd570..5247b94b76e4 100644 --- a/apps/isaaclab.python.headless.rendering.kit +++ b/apps/isaaclab.python.headless.rendering.kit @@ -109,7 +109,7 @@ exts."omni.replicator.core".Orchestrator.enabled = false metricsAssembler.changeListenerEnabled = false # explicitly disable omni.kit.pip_archive to prevent conflicting dependencies -app.extensions.excluded = ["omni.kit.pip_archive"] +app.extensions.excluded = ["omni.kit.pip_archive", "omni.isaac.ml_archive", "isaacsim.pip.newton", "omni.warp.core"] [settings.app.python] # These disable the kit app from also printing out python output, which gets confusing diff --git a/apps/isaaclab.python.kit b/apps/isaaclab.python.kit index cc54d43a4da3..a5330cd1944e 100644 --- a/apps/isaaclab.python.kit +++ b/apps/isaaclab.python.kit @@ -92,7 +92,6 @@ keywords = ["experience", "app", "usd"] "omni.uiaudio" = {} "omni.usd.metrics.assembler.ui" = {} "omni.usd.schema.metrics.assembler" = {} -"omni.warp.core" = {} ######################## # Isaac Lab Extensions # @@ -257,7 +256,7 @@ exts."omni.replicator.core".Orchestrator.enabled = false omni.rtx.nre.compositing.rendererHints = 3 # explicitly disable omni.kit.pip_archive to prevent conflicting dependencies -app.extensions.excluded = ["omni.kit.pip_archive"] +app.extensions.excluded = ["omni.kit.pip_archive", "omni.isaac.ml_archive", "isaacsim.pip.newton", "omni.warp.core"] [settings.app.livestream] outDirectory = "${data}" diff --git a/apps/isaaclab.python.rendering.kit b/apps/isaaclab.python.rendering.kit index ad58b159186a..d3414b211907 100644 --- a/apps/isaaclab.python.rendering.kit +++ b/apps/isaaclab.python.rendering.kit @@ -93,7 +93,7 @@ exts."omni.replicator.core".Orchestrator.enabled = false metricsAssembler.changeListenerEnabled = false # explicitly disable omni.kit.pip_archive to prevent conflicting dependencies -app.extensions.excluded = ["omni.kit.pip_archive"] +app.extensions.excluded = ["omni.kit.pip_archive", "omni.isaac.ml_archive", "isaacsim.pip.newton", "omni.warp.core"] [settings.physics] updateToUsd = false diff --git a/apps/isaaclab.python.xr.openxr.headless.kit b/apps/isaaclab.python.xr.openxr.headless.kit index 6bf1bb7c43d6..75f3ae637124 100644 --- a/apps/isaaclab.python.xr.openxr.headless.kit +++ b/apps/isaaclab.python.xr.openxr.headless.kit @@ -42,7 +42,7 @@ cameras_enabled = true [settings] # explicitly disable omni.kit.pip_archive to prevent conflicting dependencies -app.extensions.excluded = ["omni.kit.pip_archive"] +app.extensions.excluded = ["omni.kit.pip_archive", "omni.isaac.ml_archive", "isaacsim.pip.newton", "omni.warp.core"] [settings.app.python] # These disable the kit app from also printing out python output, which gets confusing diff --git a/apps/isaaclab.python.xr.openxr.kit b/apps/isaaclab.python.xr.openxr.kit index 3b730f0eab64..62782ba06619 100644 --- a/apps/isaaclab.python.xr.openxr.kit +++ b/apps/isaaclab.python.xr.openxr.kit @@ -66,7 +66,7 @@ xr.openxr.components."isaacsim.xr.openxr.hand_tracking".enabled = true xr.openxr.components."isaacsim.kit.xr.teleop.bridge".enabled = true # explicitly disable omni.kit.pip_archive to prevent conflicting dependencies -app.extensions.excluded = ["omni.kit.pip_archive"] +app.extensions.excluded = ["omni.kit.pip_archive", "omni.isaac.ml_archive", "isaacsim.pip.newton", "omni.warp.core"] [settings.app.python] # These disable the kit app from also printing out python output, which gets confusing diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index 37306fcbae39..6787a42766ef 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -1672,6 +1672,33 @@ Deprecated retargeters have been moved to ``isaaclab_teleop.deprecated.openxr.re compatibility. These will be removed in a future release. +PhysX Tensors API Module Path +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Recent Isaac Sim releases removed the internal ``impl`` submodule of +``omni.physics.tensors`` and now expose the PhysX Tensor API types +(``ArticulationView``, ``RigidBodyView``, ``SimulationView``, etc.) directly +under ``omni.physics.tensors.api``. Importing from the old path raises +``ModuleNotFoundError: No module named 'omni.physics.tensors.impl'`` at import +time. + +Isaac Lab has been updated to import from the new path. Downstream code +(custom assets, sensors, or scripts) that imported from the old path must be +updated: + +.. code-block:: python + + # Before (Isaac Lab 2.x / older Isaac Sim) + import omni.physics.tensors.impl.api as physx + + # After (Isaac Lab 3.x / current Isaac Sim) + import omni.physics.tensors.api as physx + +The class identities are unchanged — only the module path moved. Type hints +referencing the old path (``omni.physics.tensors.impl.api.ArticulationView``) +should be similarly updated to ``omni.physics.tensors.api.ArticulationView``. + + Need Help? ~~~~~~~~~~ diff --git a/docs/source/overview/core-concepts/sensors/contact_sensor.rst b/docs/source/overview/core-concepts/sensors/contact_sensor.rst index 4ddd2d10c077..ebd81e60a54d 100644 --- a/docs/source/overview/core-concepts/sensors/contact_sensor.rst +++ b/docs/source/overview/core-concepts/sensors/contact_sensor.rst @@ -55,7 +55,7 @@ Here, we print both the net contact force and the filtered force matrix for each ------------------------------- Contact sensor @ '/World/envs/env_.*/Robot/LF_FOOT': - view type : + view type : update period (s) : 0.0 number of bodies : 1 body names : ['LF_FOOT'] @@ -64,7 +64,7 @@ Here, we print both the net contact force and the filtered force matrix for each Received contact force of: tensor([[[-1.3923e-05, 1.5727e-04, 1.1032e+02]]], device='cuda:0') ------------------------------- Contact sensor @ '/World/envs/env_.*/Robot/RF_FOOT': - view type : + view type : update period (s) : 0.0 number of bodies : 1 body names : ['RF_FOOT'] @@ -85,7 +85,7 @@ Notice that even with filtering, both sensors report the net contact force actin ------------------------------- Contact sensor @ '/World/envs/env_.*/Robot/.*H_FOOT': - view type : + view type : update period (s) : 0.0 number of bodies : 2 body names : ['LH_FOOT', 'RH_FOOT'] diff --git a/docs/source/overview/core-concepts/sensors/imu.rst b/docs/source/overview/core-concepts/sensors/imu.rst index fe429f77a2b3..5435bf227f5d 100644 --- a/docs/source/overview/core-concepts/sensors/imu.rst +++ b/docs/source/overview/core-concepts/sensors/imu.rst @@ -61,7 +61,7 @@ The oscillations in the values reported by the sensor are a direct result of of .. code-block:: bash Imu sensor @ '/World/envs/env_.*/Robot/LF_FOOT': - view type : + view type : update period (s) : 0.0 number of sensors : 1 @@ -71,7 +71,7 @@ The oscillations in the values reported by the sensor are a direct result of of Received angular acceleration: tensor([[-0.0389, -0.0262, -0.0045]], device='cuda:0') ------------------------------- Imu sensor @ '/World/envs/env_.*/Robot/RF_FOOT': - view type : + view type : update period (s) : 0.0 number of sensors : 1 diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 3086a2b93c88..6c8affee3d78 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.12" +version = "4.6.14" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index ce6db83354dd..8b9a14c4b275 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,38 @@ Changelog --------- +4.6.14 (2026-04-24) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.envs.mdp.randomize_visual_color` and + :class:`~isaaclab.envs.mdp.randomize_visual_texture` failing with + ``'rtx::neuraylib::MdlModuleId' for '' is Invalid`` on Replicator >= 1.13.0. + Kit's ``omni_usd_resolver`` intentionally returns an empty string when resolving + builtin MDL short-names such as ``OmniPBR.mdl`` (``OMNI_USD_RESOLVER_MDL_BUILTIN_BYPASS=1``), + but Replicator's ``create_sdf_spec_material`` now passes that empty resolved path directly + into ``UsdMdl.RegistryUtils.GetSubIdentifiersForAsset``. The fix pre-resolves the absolute + on-disk path via ``carb.tokens`` (``${kit}/mdl/core/Base/OmniPBR.mdl``) before handing it + to Replicator so the resolver returns a valid path. + + +4.6.13 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed ``isaaclab.sh --install`` leaving ``pinocchio`` uninstalled on top of recent Isaac Sim + base images that preinstall ``pin-pink`` in the kit's bundled ``site-packages`` without its + ``pin`` (cmeel pinocchio) dependency. Pip treats the ``pin-pink`` requirement as already + satisfied and skips the transitive ``pin`` resolve, so the pink IK controller and its tests + fail to import. ``isaaclab.cli.commands.install`` now probes ``import pinocchio`` after + installing the Isaac Lab submodules and force-reinstalls the cmeel ``pin``/``pin-pink``/ + ``daqp`` stack when the probe fails. + + 4.6.12 (2026-04-23) ~~~~~~~~~~~~~~~~~~~ @@ -76,6 +108,9 @@ Changed global (world-frame) and local (body-frame) buffers. A new :meth:`~isaaclab.utils.wrench_composer.WrenchComposer.compose_to_body_frame` method rotates global forces/torques into the body frame at apply time using the current body orientation, then sums with local forces/torques. +* Updated imports of the PhysX tensors API in the ray caster sensors from + ``omni.physics.tensors.impl.api`` to ``omni.physics.tensors.api`` to track the upstream + Isaac Sim module relocation (the ``impl`` submodule was removed). Deprecated ^^^^^^^^^^ diff --git a/source/isaaclab/isaaclab/__init__.py b/source/isaaclab/isaaclab/__init__.py index c3e1b9450b7a..d547b628372e 100644 --- a/source/isaaclab/isaaclab/__init__.py +++ b/source/isaaclab/isaaclab/__init__.py @@ -6,6 +6,79 @@ """Package containing the core framework.""" import os +import sys + + +def _deprioritize_prebundle_paths(): + """Move Isaac Sim ``pip_prebundle`` and known conflicting extension directories to the end of ``sys.path``. + + Isaac Sim's ``setup_python_env.sh`` injects ``pip_prebundle`` directories + onto ``PYTHONPATH``. These contain older copies of packages like torch, + warp, and nvidia-cudnn that shadow the versions installed by Isaac Lab, + causing CUDA runtime errors. + + Additionally, certain Isaac Sim kit extensions (such as ``omni.warp.core``) + bundle their own copies of Python packages that conflict with pip-installed + versions. When loaded by the extension system these paths can appear on + ``sys.path`` before ``site-packages``, leading to version mismatches. + + Rather than removing these paths entirely (which would break packages like + ``sympy`` that only exist in the prebundle), this function moves them to + the **end** of ``sys.path`` so that pip-installed packages in + ``site-packages`` take priority. + + The ``PYTHONPATH`` environment variable is also rewritten so that child + processes inherit the corrected ordering. + """ + + # Extension directory fragments that are known to ship Python packages + # which conflict with Isaac Lab's pip-installed versions. + _CONFLICTING_EXT_FRAGMENTS = ( + "omni.warp.core", + "omni.isaac.ml_archive", + "omni.isaac.core_archive", + "omni.kit.pip_archive", + "isaacsim.pip.newton", + ) + + def _should_demote(path: str) -> bool: + norm = path.replace("\\", "/").lower() + if "pip_prebundle" in norm: + return True + for frag in _CONFLICTING_EXT_FRAGMENTS: + if frag.lower() in norm: + return True + return False + + # Partition: keep non-conflicting in place, collect conflicting. + clean = [] + demoted = [] + for p in sys.path: + if _should_demote(p): + demoted.append(p) + else: + clean.append(p) + + if not demoted: + return + + # Rebuild sys.path: originals first, then demoted at the very end. + sys.path[:] = clean + demoted + + # Rewrite PYTHONPATH with the same ordering for subprocesses. + if "PYTHONPATH" in os.environ: + parts = os.environ["PYTHONPATH"].split(os.pathsep) + env_clean = [] + env_demoted = [] + for p in parts: + if _should_demote(p): + env_demoted.append(p) + else: + env_clean.append(p) + os.environ["PYTHONPATH"] = os.pathsep.join(env_clean + env_demoted) + + +_deprioritize_prebundle_paths() # Conveniences to other module directories via relative paths. ISAACLAB_EXT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../")) diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index c9e18b54f54d..0322713799e4 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -241,6 +241,15 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa self._create_app() # Load IsaacSim extensions self._load_extensions() + + # Re-run path sanitization. Kit and its extensions may have inserted + # additional ``pip_prebundle`` or conflicting extension directories onto + # ``sys.path`` during startup. A second pass ensures pip-installed + # packages still take priority over bundled copies. + from isaaclab import _deprioritize_prebundle_paths + + _deprioritize_prebundle_paths() + # Hide the stop button in the toolbar self._hide_stop_button() # Set settings from the given rendering mode diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py index 61ee8f28e7a9..01dab490183a 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py @@ -614,7 +614,7 @@ def body_incoming_joint_wrench_b(self) -> wp.array: underlying `PhysX Tensor API`_. .. _PhysX documentation: https://nvidia-omniverse.github.io/PhysX/physx/5.5.1/docs/Articulations.html#link-incoming-joint-force - .. _PhysX Tensor API: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.impl.api.ArticulationView.get_link_incoming_joint_force + .. _PhysX Tensor API: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.api.ArticulationView.get_link_incoming_joint_force """ raise NotImplementedError diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index 53cf4a799fb1..fb903d59573b 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -77,6 +77,119 @@ def _install_system_deps() -> None: run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) +def _torch_first_on_sys_path_is_prebundle(python_exe: str, *, env: dict[str, str]) -> bool: + """Return True when the first ``torch`` on ``sys.path`` comes from a prebundle directory. + + Checks whether the first directory on ``sys.path`` that contains a + ``torch`` package lives under a ``pip_prebundle`` path (e.g. + ``omni.isaac.ml_archive/pip_prebundle``). This catches the prebundle + regardless of whether the extension lives under ``exts/``, + ``extsDeprecated/``, or any other search path. + + Does not import ``torch`` (that can fail on missing ``libcudnn`` while the + prebundle still appears earlier on ``sys.path`` than ``site-packages``). + """ + probe = """import os, sys +for p in sys.path: + if not p: + continue + if os.path.isfile(os.path.join(p, "torch", "__init__.py")): + norm = os.path.normpath(p) + sys.exit(1 if "pip_prebundle" in norm else 0) +sys.exit(0) +""" + result = run_command( + [python_exe, "-c", probe], + env=env, + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 1 + + +def _maybe_uninstall_prebundled_torch( + python_exe: str, + pip_cmd: list[str], + using_uv: bool, + *, + probe_env: dict[str, str], +) -> None: + """Uninstall pip torch stack when ``sys.path`` would load ``torch`` from a prebundle first.""" + if not _torch_first_on_sys_path_is_prebundle(python_exe, env=probe_env): + return + print_info( + "The first ``torch`` on ``sys.path`` is under a prebundle directory (e.g. " + "``omni.isaac.ml_archive/pip_prebundle``). Uninstalling pip " + "``torch``/``torchvision``/``torchaudio`` before continuing." + ) + uninstall_flags = ["-y"] if not using_uv else [] + run_command( + pip_cmd + ["uninstall"] + uninstall_flags + ["torch", "torchvision", "torchaudio"], + check=False, + ) + + +# Pinocchio stack required by isaaclab.controllers.pink_ik. Installed via the cmeel +# ``pin`` wheel, which provides the ``pinocchio`` Python module under +# ``cmeel.prefix/lib/python3.12/site-packages/`` and registers it on sys.path via a +# ``cmeel.pth`` hook. +_PINOCCHIO_STACK = ("pin", "pin-pink==3.1.0", "daqp==0.7.2") + + +def _ensure_pinocchio_installed(python_exe: str, pip_cmd: list[str], *, probe_env: dict[str, str]) -> None: + """Ensure ``pinocchio`` is importable, force-installing the cmeel pin stack if not. + + Recent Isaac Sim base images preinstall ``pin-pink`` into the kit's bundled + ``site-packages`` without its ``pin`` (cmeel pinocchio) dependency. Pip then + treats the ``pin-pink`` requirement as satisfied and never resolves the + transitive ``pin`` dep, leaving ``import pinocchio`` broken. This probes + for ``pinocchio`` at runtime and force-installs the cmeel stack when needed + so the pink IK controller and its tests work out of the box. + + Only runs on Linux x86_64 / aarch64 — the same platforms that have + pinocchio listed in :mod:`isaaclab`'s ``setup.py`` install requirements. + Skipped on Windows and macOS (no cmeel wheels) and on unsupported + architectures so the rest of ``--install`` behaves unchanged there. + + A force-reinstall failure (e.g. transient PyPI / NVIDIA Artifactory issue) + is logged as a warning rather than aborting ``--install``: pinocchio is only + needed by the optional pink IK controller, so the rest of Isaac Lab should + still install cleanly. + """ + import platform + + if platform.system() != "Linux": + return + if platform.machine() not in {"x86_64", "AMD64", "aarch64", "arm64"}: + return + + probe_result = run_command( + [python_exe, "-c", "import pinocchio"], + env=probe_env, + check=False, + capture_output=True, + text=True, + ) + if probe_result.returncode == 0: + return + + print_info( + "``import pinocchio`` failed — the kit-bundled ``pin-pink`` likely shipped without its" + " ``pin`` dep. Force-installing the cmeel pinocchio stack." + ) + install_result = run_command( + pip_cmd + ["install", "--upgrade", "--force-reinstall", *_PINOCCHIO_STACK], + check=False, + ) + if install_result.returncode != 0: + print_warning( + "Force-installing the cmeel pinocchio stack failed (returncode " + f"{install_result.returncode}). The pink IK controller and its tests will not be" + " usable until ``pin pin-pink==3.1.0 daqp==0.7.2`` is installed manually." + ) + + def _ensure_cuda_torch() -> None: """Ensure correct PyTorch and CUDA versions are installed.""" python_exe = extract_python_exe() @@ -389,6 +502,17 @@ def _repoint_prebundle_packages() -> None: if not prebundled.exists() and not prebundled.is_symlink(): continue + # The 'nvidia' directory is a Python namespace package shared across many + # distributions (nvidia-cudnn-cu12, nvidia-cublas-cu12, nvidia-srl, …). + # When using Isaac Sim's built-in Python, site-packages/nvidia only contains + # 'srl'; replacing the whole prebundle nvidia/ with that symlink strips away + # the CUDA shared libraries (libcudnn.so.9, etc.) that torch needs. + # Only repoint the nvidia namespace when the target actually provides the + # CUDA subpackages (cudnn is the minimal required indicator). + if pkg_name == "nvidia" and not (venv_pkg / "cudnn").exists(): + print_debug(f"Skipping repoint of {prebundled}: {venv_pkg} lacks CUDA subpackages (cudnn missing).") + continue + try: if prebundled.is_symlink(): if prebundled.resolve() == venv_pkg.resolve(): @@ -543,6 +667,12 @@ def command_install(install_type: str = "all") -> None: pip_cmd = get_pip_command(python_exe) using_uv = pip_cmd[0] == "uv" + # Probe with the user's original PYTHONPATH (before pip-time filtering) so we detect + # Isaac Sim's setup_python_env.sh ordering that prefers extsDeprecated/ml_archive. + probe_env = {**os.environ} + if saved_pythonpath is not None: + probe_env["PYTHONPATH"] = saved_pythonpath + try: # Upgrade pip first to avoid compatibility issues (skip when using uv). if not using_uv: @@ -552,6 +682,9 @@ def command_install(install_type: str = "all") -> None: # Pin setuptools to avoid issues with pkg_resources removal in 82.0.0. run_command(pip_cmd + ["install", "setuptools<82.0.0"]) + # Drop pip-installed torch if Isaac Sim's deprecated ML prebundle would shadow it. + _maybe_uninstall_prebundled_torch(python_exe, pip_cmd, using_uv, probe_env=probe_env) + # Install Isaac Sim if requested. if install_isaacsim: _install_isaacsim() @@ -570,6 +703,11 @@ def command_install(install_type: str = "all") -> None: # Can prevent that from happening. _ensure_cuda_torch() + # Ensure ``pinocchio`` is actually importable. The kit-bundled ``pin-pink`` in recent + # Isaac Sim images ships without its cmeel ``pin`` dependency, so the transitive + # requirement from ``pip install -e source/isaaclab`` can be silently skipped. + _ensure_pinocchio_installed(python_exe, pip_cmd, probe_env=probe_env) + # Repoint prebundled packages in Isaac Sim to the environment's copies so # the active venv/conda versions are always loaded regardless of PYTHONPATH # ordering (e.g. torch+cu130 in venv vs torch+cu128 in prebundle on aarch64). diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index 2209ea5a28f5..06e5e5bb1c32 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -2132,9 +2132,18 @@ def rep_texture_randomization(): if prim.IsInstanceable(): prim.SetInstanceable(False) + # Resolve OmniPBR.mdl to an absolute path so that pxr.Ar.GetResolver().Resolve() + # returns a valid path. Kit's omni_usd_resolver intentionally returns "" for builtin + # MDL short-names (OMNI_USD_RESOLVER_MDL_BUILTIN_BYPASS=1), which causes Replicator + # >= 1.13.0 to pass an empty resolved path into UsdMdl.RegistryUtils, raising a + # 'rtx::neuraylib::MdlModuleId' is Invalid error. + import carb.tokens # noqa: PLC0415 + + omni_pbr_mdl = carb.tokens.get_tokens_interface().resolve("${kit}/mdl/core/Base/OmniPBR.mdl") + # TODO: Should we specify the value when creating the material? self.material_prims = rep.functional.create_batch.material( - mdl="OmniPBR.mdl", bind_prims=prims_group, count=num_prims, project_uvw=True + mdl=omni_pbr_mdl, bind_prims=prims_group, count=num_prims, project_uvw=True ) def __call__( @@ -2281,9 +2290,18 @@ def rep_color_randomization(): if prim.IsInstanceable(): prim.SetInstanceable(False) + # Resolve OmniPBR.mdl to an absolute path so that pxr.Ar.GetResolver().Resolve() + # returns a valid path. Kit's omni_usd_resolver intentionally returns "" for builtin + # MDL short-names (OMNI_USD_RESOLVER_MDL_BUILTIN_BYPASS=1), which causes Replicator + # >= 1.13.0 to pass an empty resolved path into UsdMdl.RegistryUtils, raising a + # 'rtx::neuraylib::MdlModuleId' is Invalid error. + import carb.tokens # noqa: PLC0415 + + omni_pbr_mdl = carb.tokens.get_tokens_interface().resolve("${kit}/mdl/core/Base/OmniPBR.mdl") + # TODO: Should we specify the value when creating the material? self.material_prims = rep.functional.create_batch.material( - mdl="OmniPBR.mdl", bind_prims=prims_group, count=num_prims, project_uvw=True + mdl=omni_pbr_mdl, bind_prims=prims_group, count=num_prims, project_uvw=True ) def __call__( diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index a705e6398438..7299ff954467 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -646,7 +646,9 @@ def _prepare_terms(self): old_dims.insert(1, term_cfg.history_length) obs_dims = tuple(old_dims) if term_cfg.flatten_history_dim: - obs_dims = (obs_dims[0], np.prod(obs_dims[1:])) + # Cast to ``int`` so the dim is a plain Python int rather than ``np.int64``; + # otherwise the tuple would render as ``(np.int64(N),)`` in __str__. + obs_dims = (obs_dims[0], int(np.prod(obs_dims[1:]))) self._group_obs_term_dim[group_name].append(obs_dims[1:]) diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py new file mode 100644 index 000000000000..a7c89ccd9d53 --- /dev/null +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -0,0 +1,786 @@ +# 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 + +"""Tests for install command functions. + +Covers all combinations of: +- Python environment types: uv venv, pip venv, conda, Isaac Sim kit Python, system Python +- Isaac Sim installation methods: local _isaac_sim symlink, pip-installed isaacsim, none +""" + +import subprocess +from contextlib import contextmanager +from pathlib import Path +from unittest import mock + +import pytest + +from isaaclab.cli.commands.install import ( + _PREBUNDLE_REPOINT_PACKAGES, + _ensure_cuda_torch, + _maybe_uninstall_prebundled_torch, + _repoint_prebundle_packages, + _torch_first_on_sys_path_is_prebundle, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _cp(returncode: int = 0, stdout: str = "") -> mock.MagicMock: + """Return a mock CompletedProcess with the given returncode and stdout.""" + r = mock.MagicMock(spec=subprocess.CompletedProcess) + r.returncode = returncode + r.stdout = stdout + return r + + +def _make_prebundle(base: Path, packages: list[str]) -> Path: + """Create a fake pip_prebundle directory populated with the given package dirs.""" + prebundle = base / "pip_prebundle" + prebundle.mkdir(parents=True) + for pkg in packages: + (prebundle / pkg).mkdir() + return prebundle + + +def _make_site_packages( + base: Path, + packages: list[str], + subdirs: dict[str, list[str]] | None = None, +) -> Path: + """Create a fake site-packages directory. + + Args: + packages: Top-level package directory names to create. + subdirs: Optional mapping of package name → list of subdirectory names to create inside it. + """ + site_pkgs = base / "site-packages" + site_pkgs.mkdir(parents=True, exist_ok=True) + for pkg in packages: + (site_pkgs / pkg).mkdir(exist_ok=True) + for pkg, subs in (subdirs or {}).items(): + for sub in subs: + (site_pkgs / pkg / sub).mkdir(parents=True, exist_ok=True) + return site_pkgs + + +# --------------------------------------------------------------------------- +# _torch_first_on_sys_path_is_prebundle +# --------------------------------------------------------------------------- + + +class TestTorchProbe: + """Tests for :func:`_torch_first_on_sys_path_is_prebundle`. + + The function shells out to ``python_exe -c `` and interprets the + subprocess exit code: 1 → prebundle is first; 0 → it is not. + """ + + def test_returns_true_when_prebundle_first(self, tmp_path): + """Probe exits 1 → the first torch on sys.path is under a pip_prebundle directory.""" + with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(returncode=1)): + result = _torch_first_on_sys_path_is_prebundle( + str(tmp_path / "python"), + env={"PYTHONPATH": "/fake/extsDeprecated/pip_prebundle"}, + ) + assert result is True + + def test_returns_false_when_site_packages_first(self, tmp_path): + """Probe exits 0 → the first torch on sys.path is in regular site-packages.""" + with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(returncode=0)): + result = _torch_first_on_sys_path_is_prebundle( + str(tmp_path / "python"), + env={"PYTHONPATH": "/conda/lib/python3.12/site-packages"}, + ) + assert result is False + + def test_returns_false_when_torch_not_found_anywhere(self, tmp_path): + """Probe exits 0 (no torch on sys.path at all) → returns False.""" + with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(returncode=0)): + result = _torch_first_on_sys_path_is_prebundle( + str(tmp_path / "python"), + env={}, + ) + assert result is False + + def test_passes_env_to_subprocess(self, tmp_path): + """The custom env dict is forwarded to run_command.""" + env_sent = {"PYTHONPATH": "/some/path"} + with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0)) as mock_run: + _torch_first_on_sys_path_is_prebundle(str(tmp_path / "python"), env=env_sent) + call_kwargs = mock_run.call_args + assert call_kwargs.kwargs.get("env") == env_sent or ( + len(call_kwargs.args) > 1 and call_kwargs.args[1] == env_sent + ) + + +# --------------------------------------------------------------------------- +# _maybe_uninstall_prebundled_torch +# --------------------------------------------------------------------------- + + +class TestMaybeUninstallTorch: + """Tests for :func:`_maybe_uninstall_prebundled_torch`.""" + + def test_does_not_uninstall_when_probe_false(self, tmp_path): + """When the probe returns False, no pip uninstall command is issued.""" + py = str(tmp_path / "python") + with ( + mock.patch( + "isaaclab.cli.commands.install._torch_first_on_sys_path_is_prebundle", + return_value=False, + ), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + _maybe_uninstall_prebundled_torch(py, [py, "-m", "pip"], using_uv=False, probe_env={}) + mock_run.assert_not_called() + + def test_uninstalls_torch_stack_with_minus_y_for_pip(self, tmp_path): + """When probe returns True and pip is in use, uninstall includes -y flag.""" + py = str(tmp_path / "python") + with ( + mock.patch( + "isaaclab.cli.commands.install._torch_first_on_sys_path_is_prebundle", + return_value=True, + ), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + _maybe_uninstall_prebundled_torch(py, [py, "-m", "pip"], using_uv=False, probe_env={}) + mock_run.assert_called_once() + issued = mock_run.call_args[0][0] + assert "uninstall" in issued + assert "-y" in issued + assert "torch" in issued + assert "torchvision" in issued + assert "torchaudio" in issued + + def test_uninstalls_torch_stack_without_minus_y_for_uv(self, tmp_path): + """When probe returns True and uv pip is in use, uninstall omits -y (uv doesn't accept it).""" + with ( + mock.patch( + "isaaclab.cli.commands.install._torch_first_on_sys_path_is_prebundle", + return_value=True, + ), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + _maybe_uninstall_prebundled_torch("/fake/python", ["uv", "pip"], using_uv=True, probe_env={}) + issued = mock_run.call_args[0][0] + assert "uninstall" in issued + assert "-y" not in issued + + def test_probe_receives_original_pythonpath(self, tmp_path): + """The probe_env dict is forwarded unchanged to the torch-probe function.""" + py = str(tmp_path / "python") + probe_env = {"PYTHONPATH": "/a/extsDeprecated/pip_prebundle:/b/site-packages"} + with mock.patch( + "isaaclab.cli.commands.install._torch_first_on_sys_path_is_prebundle", + return_value=False, + ) as mock_probe: + _maybe_uninstall_prebundled_torch(py, [py, "-m", "pip"], using_uv=False, probe_env=probe_env) + mock_probe.assert_called_once_with(py, env=probe_env) + + +# --------------------------------------------------------------------------- +# _ensure_cuda_torch — architecture × environment combinations +# --------------------------------------------------------------------------- + + +class TestEnsureCudaTorch: + """Tests for :func:`_ensure_cuda_torch` across architectures and environment types. + + Combinations tested: + - Architecture: x86 (cu128) vs ARM (cu130) + - Pip command: ``python -m pip`` (venv/conda/kit) vs ``uv pip`` (uv venv) + - Torch state: already installed at correct version; wrong CUDA tag; not installed + """ + + # ---- x86 scenarios ------------------------------------------------------- + + def test_x86_skips_install_when_correct_version_present(self, tmp_path): + """x86: torch 2.10.0+cu128 already installed → pip install is not called.""" + py = str(tmp_path / "python") + pip_cmd = [py, "-m", "pip"] + pip_show_out = "Name: torch\nVersion: 2.10.0+cu128\n" + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, pip_show_out)) as mock_run, + ): + _ensure_cuda_torch() + + # Only the initial ``pip show torch`` call; no install. + assert mock_run.call_count == 1 + assert "show" in mock_run.call_args[0][0] + + def test_x86_installs_cu128_when_torch_missing(self, tmp_path): + """x86: no torch installed → installs torch+cu128 from pytorch.org/whl/cu128.""" + py = str(tmp_path / "python") + pip_cmd = [py, "-m", "pip"] + calls: list[list[str]] = [] + + def _run(cmd, **kwargs): + calls.append(list(cmd)) + return _cp(0, "") # pip show returns nothing → torch absent + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), + ): + _ensure_cuda_torch() + + install_cmds = [c for c in calls if "install" in c] + combined = " ".join(str(t) for c in install_cmds for t in c) + assert "cu128" in combined + assert "torch" in combined + + def test_x86_reinstalls_when_wrong_cuda_tag(self, tmp_path): + """x86: torch+cu130 installed (ARM build) → uninstalls and reinstalls as cu128.""" + py = str(tmp_path / "python") + pip_cmd = [py, "-m", "pip"] + calls: list[list[str]] = [] + + def _run(cmd, **kwargs): + calls.append(list(cmd)) + stdout = "Name: torch\nVersion: 2.10.0+cu130\n" if "show" in cmd else "" + return _cp(0, stdout) + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), + ): + _ensure_cuda_torch() + + assert any("uninstall" in c for c in calls), "Expected an uninstall call" + install_cmds = [c for c in calls if "install" in c] + combined = " ".join(str(t) for c in install_cmds for t in c) + assert "cu128" in combined + + # ---- ARM scenarios ------------------------------------------------------- + + def test_arm_installs_cu130_when_torch_missing(self, tmp_path): + """ARM: no torch installed → installs torch+cu130 from pytorch.org/whl/cu130.""" + py = str(tmp_path / "python") + pip_cmd = [py, "-m", "pip"] + calls: list[list[str]] = [] + + def _run(cmd, **kwargs): + calls.append(list(cmd)) + return _cp(0, "") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=True), + mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), + ): + _ensure_cuda_torch() + + install_cmds = [c for c in calls if "install" in c] + combined = " ".join(str(t) for c in install_cmds for t in c) + assert "cu130" in combined + + def test_arm_skips_install_when_correct_version_present(self, tmp_path): + """ARM: torch 2.10.0+cu130 already installed → pip install is not called.""" + py = str(tmp_path / "python") + pip_cmd = [py, "-m", "pip"] + pip_show_out = "Name: torch\nVersion: 2.10.0+cu130\n" + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=True), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, pip_show_out)) as mock_run, + ): + _ensure_cuda_torch() + + assert mock_run.call_count == 1 + + def test_arm_reinstalls_when_wrong_cuda_tag(self, tmp_path): + """ARM: torch+cu128 installed (x86 build) → uninstalls and reinstalls as cu130.""" + py = str(tmp_path / "python") + pip_cmd = [py, "-m", "pip"] + calls: list[list[str]] = [] + + def _run(cmd, **kwargs): + calls.append(list(cmd)) + stdout = "Name: torch\nVersion: 2.10.0+cu128\n" if "show" in cmd else "" + return _cp(0, stdout) + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=True), + mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), + ): + _ensure_cuda_torch() + + assert any("uninstall" in c for c in calls) + install_cmds = [c for c in calls if "install" in c] + combined = " ".join(str(t) for c in install_cmds for t in c) + assert "cu130" in combined + + # ---- uv venv environment ------------------------------------------------ + + def test_uv_venv_uses_uv_pip_command(self, tmp_path): + """In a uv venv get_pip_command returns ['uv', 'pip'] and uninstall omits -y.""" + py = str(tmp_path / "python") + calls: list[list[str]] = [] + + def _run(cmd, **kwargs): + calls.append(list(cmd)) + return _cp(0, "") # no current torch → triggers install + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=["uv", "pip"]), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), + ): + _ensure_cuda_torch() + + assert calls[0][0] == "uv", "Expected uv as the pip command prefix" + uninstall_calls = [c for c in calls if "uninstall" in c] + assert uninstall_calls, "Expected an uninstall call before reinstall" + assert "-y" not in uninstall_calls[0], "uv pip uninstall must not include -y" + + # ---- conda / pip venv / kit Python environments ------------------------- + + def test_conda_uses_python_m_pip_with_minus_y(self, tmp_path): + """In a conda env (no uv), get_pip_command returns python -m pip; uninstall uses -y.""" + py = str(tmp_path / "conda" / "bin" / "python") + pip_cmd = [py, "-m", "pip"] + calls: list[list[str]] = [] + + def _run(cmd, **kwargs): + calls.append(list(cmd)) + return _cp(0, "") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), + ): + _ensure_cuda_torch() + + uninstall_calls = [c for c in calls if "uninstall" in c] + assert uninstall_calls + assert "-y" in uninstall_calls[0], "pip uninstall must include -y" + assert py in uninstall_calls[0], "Expected python exe in pip command" + + def test_kit_python_uses_python_sh_as_pip_prefix(self, tmp_path): + """With Isaac Sim's kit Python, python.sh is the executable prefix in the pip command.""" + python_sh = str(tmp_path / "_isaac_sim" / "python.sh") + pip_cmd = [python_sh, "-m", "pip"] + calls: list[list[str]] = [] + + def _run(cmd, **kwargs): + calls.append(list(cmd)) + return _cp(0, "") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=python_sh), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch("isaaclab.cli.commands.install.is_arm", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), + ): + _ensure_cuda_torch() + + assert calls[0][0] == python_sh + + +# --------------------------------------------------------------------------- +# _repoint_prebundle_packages — Isaac Sim install method × venv type +# --------------------------------------------------------------------------- + + +class TestRePointPrebundlePackages: + """Tests for :func:`_repoint_prebundle_packages`. + + Covers all combinations of: + - Isaac Sim installation method: local _isaac_sim symlink, pip-installed isaacsim, none + - Python environment / site-packages source: uv venv, pip venv, conda, kit Python + - nvidia namespace package special handling: cudnn present vs absent + """ + + # ---- shared fixtures / helpers ------------------------------------------ + + def _sim_with_prebundle(self, base: Path, packages: list[str]) -> tuple[Path, Path]: + """Create a minimal fake Isaac Sim tree containing a pip_prebundle dir. + + Returns ``(isaacsim_path, prebundle_dir)``. + """ + isaacsim_path = base / "isaac_sim" + isaacsim_path.mkdir(parents=True) + prebundle = isaacsim_path / "exts" / "some.ext" / "pip_prebundle" + prebundle.mkdir(parents=True) + for pkg in packages: + (prebundle / pkg).mkdir() + return isaacsim_path, prebundle + + @contextmanager + def _patch(self, isaacsim_path: Path | None, site_packages: Path, python_exe: str): + """Context manager that mocks all external calls in _repoint_prebundle_packages.""" + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=python_exe), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), + mock.patch( + "isaaclab.cli.commands.install.run_command", + return_value=_cp(0, str(site_packages)), + ), + ): + yield + + # ---- no Isaac Sim -------------------------------------------------------- + + def test_no_op_when_isaac_sim_absent(self, tmp_path): + """When Isaac Sim is not found, _repoint_prebundle_packages returns immediately without touching anything.""" + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=None), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + _repoint_prebundle_packages() + mock_run.assert_not_called() + + # ---- no pip_prebundle directories ---------------------------------------- + + def test_no_op_when_no_pip_prebundle_dirs(self, tmp_path): + """When Isaac Sim has no pip_prebundle directories, nothing is repointed.""" + isaacsim_path = tmp_path / "isaac_sim" + isaacsim_path.mkdir() + site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) + py = str(tmp_path / "python") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert not (site_pkgs.parent / "pip_prebundle" / "torch").exists() + + # ---- local _isaac_sim symlink (local build) ------------------------------ + + def test_local_build_symlinks_torch_to_venv_site_packages(self, tmp_path): + """Local _isaac_sim symlink + uv/pip venv: prebundle torch → venv site-packages/torch.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) + site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) + py = str(tmp_path / "env" / "bin" / "python") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + symlink = prebundle / "torch" + assert symlink.is_symlink(), "torch should be a symlink after repoint" + assert symlink.resolve() == (site_pkgs / "torch").resolve() + assert (prebundle / "torch.bak").is_dir(), "Original torch should be backed up" + + def test_local_build_skips_nvidia_when_cudnn_absent_kit_python(self, tmp_path): + """Local build + kit Python: site-packages/nvidia has only 'srl' (no cudnn) → nvidia NOT repointed. + + This is the real-world failure mode that caused the libcudnn.so.9 import error: + kit Python's site-packages/nvidia has only the 'srl' namespace sub-package, so + replacing the prebundle's nvidia/ (which contains the CUDA shared libraries) with + a symlink to that stripped-down directory would make libcudnn.so.9 unreachable. + """ + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["nvidia"]) + # Simulate kit Python's site-packages: nvidia/ exists but contains only 'srl' + site_pkgs = _make_site_packages(tmp_path / "kit" / "python" / "site-packages", ["nvidia"]) + (site_pkgs / "nvidia" / "srl").mkdir() + py = str(tmp_path / "isaac_sim" / "python.sh") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert not (prebundle / "nvidia").is_symlink(), "nvidia must NOT be repointed when cudnn is missing" + assert (prebundle / "nvidia").is_dir(), "Original nvidia directory must be preserved" + + def test_local_build_repoints_nvidia_when_cudnn_present_venv(self, tmp_path): + """Local build + CUDA-capable venv: site-packages/nvidia has cudnn → nvidia IS repointed. + + This covers the conda or pip venv case where the user installed torch+cu128/cu130 + with its nvidia-cudnn-cu12 dependency, giving site-packages/nvidia/cudnn/. + """ + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["nvidia"]) + # Full CUDA venv: nvidia/ has cudnn and cublas + site_pkgs = _make_site_packages( + tmp_path / "env", + ["nvidia"], + subdirs={"nvidia": ["cudnn", "cublas"]}, + ) + py = str(tmp_path / "env" / "bin" / "python") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + symlink = prebundle / "nvidia" + assert symlink.is_symlink(), "nvidia should be repointed when cudnn is present" + assert symlink.resolve() == (site_pkgs / "nvidia").resolve() + + def test_idempotent_when_symlink_already_correct(self, tmp_path): + """Calling _repoint_prebundle_packages twice does not break the symlinks.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", []) + site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) + py = str(tmp_path / "env" / "bin" / "python") + + # Pre-create the correct symlink (as if a previous install already ran). + (prebundle / "torch").symlink_to(site_pkgs / "torch") + original_target = (prebundle / "torch").resolve() + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert (prebundle / "torch").resolve() == original_target, "Correct symlink must not be changed" + + def test_updates_stale_symlink_pointing_to_old_env(self, tmp_path): + """A symlink from a previous venv that no longer matches current site-packages is updated.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", []) + site_pkgs = _make_site_packages(tmp_path / "env_new", ["torch"]) + old_env = _make_site_packages(tmp_path / "env_old", ["torch"]) + py = str(tmp_path / "env_new" / "bin" / "python") + + # Pre-create a stale symlink pointing at the old env. + (prebundle / "torch").symlink_to(old_env / "torch") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert (prebundle / "torch").resolve() == (site_pkgs / "torch").resolve(), "Stale symlink must be updated" + + def test_removes_old_backup_before_renaming(self, tmp_path): + """A pre-existing .bak directory is removed before the current package is backed up.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) + site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) + py = str(tmp_path / "env" / "bin" / "python") + + # Simulate leftover backup from a previous partial install. + old_backup = prebundle / "torch.bak" + old_backup.mkdir() + (old_backup / "stale_file.py").touch() + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert (prebundle / "torch").is_symlink(), "torch must be repointed" + # The old backup was replaced by the fresh backup. + assert (prebundle / "torch.bak").is_dir() + + # ---- pip-installed isaacsim (path found via import probe) ---------------- + + def test_pip_isaacsim_symlinks_torch(self, tmp_path): + """pip-installed isaacsim: extract_isaacsim_path() returns its directory and torch is repointed.""" + # With pip-installed isaacsim the path may be inside site-packages rather than a symlink + # at the repo root, but _repoint_prebundle_packages treats it identically. + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "pip_isaacsim", ["torch"]) + site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) + py = str(tmp_path / "env" / "bin" / "python") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert (prebundle / "torch").is_symlink() + assert (prebundle / "torch").resolve() == (site_pkgs / "torch").resolve() + + def test_pip_isaacsim_skips_nvidia_without_cudnn(self, tmp_path): + """pip-installed isaacsim + no cudnn in site-packages → nvidia prebundle preserved.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "pip_isaacsim", ["nvidia"]) + # site-packages has nvidia/ but without a cudnn sub-package + site_pkgs = _make_site_packages(tmp_path / "env", ["nvidia"]) + py = str(tmp_path / "env" / "bin" / "python") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert not (prebundle / "nvidia").is_symlink(), "nvidia must not be repointed without cudnn" + + # ---- different venv types ------------------------------------------------ + + def test_uv_venv_repoints_torch_using_venv_site_packages(self, tmp_path): + """uv venv: site-packages inside VIRTUAL_ENV is used as the symlink target.""" + venv_site = tmp_path / "env_uv" / "lib" / "python3.12" / "site-packages" + venv_site.mkdir(parents=True) + (venv_site / "torch").mkdir() + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) + py = str(tmp_path / "env_uv" / "bin" / "python") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(venv_site))), + ): + _repoint_prebundle_packages() + + assert (prebundle / "torch").is_symlink() + assert (prebundle / "torch").resolve() == (venv_site / "torch").resolve() + + def test_conda_repoints_torch_using_conda_site_packages(self, tmp_path): + """conda env: site-packages inside CONDA_PREFIX is used as the symlink target.""" + conda_site = tmp_path / "conda" / "lib" / "python3.12" / "site-packages" + conda_site.mkdir(parents=True) + (conda_site / "torch").mkdir() + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) + py = str(tmp_path / "conda" / "bin" / "python") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(conda_site))), + ): + _repoint_prebundle_packages() + + assert (prebundle / "torch").is_symlink() + assert (prebundle / "torch").resolve() == (conda_site / "torch").resolve() + + def test_conda_repoints_nvidia_when_full_cuda_torch_installed(self, tmp_path): + """conda env with nvidia-cudnn-cu12 installed: nvidia/ is repointed because cudnn subdir exists.""" + conda_site = tmp_path / "conda" / "lib" / "python3.12" / "site-packages" + conda_site.mkdir(parents=True) + (conda_site / "nvidia").mkdir() + (conda_site / "nvidia" / "cudnn").mkdir() + (conda_site / "nvidia" / "cublas").mkdir() + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["nvidia"]) + py = str(tmp_path / "conda" / "bin" / "python") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(conda_site))), + ): + _repoint_prebundle_packages() + + assert (prebundle / "nvidia").is_symlink() + + def test_conda_skips_nvidia_when_no_cudnn(self, tmp_path): + """conda env without CUDA torch: site-packages/nvidia lacks cudnn → nvidia not repointed.""" + conda_site = tmp_path / "conda" / "lib" / "python3.12" / "site-packages" + conda_site.mkdir(parents=True) + (conda_site / "nvidia").mkdir() # exists but no cudnn inside + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["nvidia"]) + py = str(tmp_path / "conda" / "bin" / "python") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(conda_site))), + ): + _repoint_prebundle_packages() + + assert not (prebundle / "nvidia").is_symlink() + + # ---- multiple prebundle directories ------------------------------------- + + def test_repoints_across_multiple_prebundle_dirs(self, tmp_path): + """When Isaac Sim has multiple pip_prebundle directories, each is processed.""" + isaacsim_path = tmp_path / "isaac_sim" + isaacsim_path.mkdir() + + # Two separate extension pip_prebundle dirs, each with torch. + pb1 = isaacsim_path / "exts" / "ext_a" / "pip_prebundle" + pb2 = isaacsim_path / "exts" / "ext_b" / "pip_prebundle" + for pb in (pb1, pb2): + pb.mkdir(parents=True) + (pb / "torch").mkdir() + + site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) + py = str(tmp_path / "env" / "bin" / "python") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + for pb in (pb1, pb2): + assert (pb / "torch").is_symlink(), f"torch in {pb} should be repointed" + + # ---- Windows: copy instead of symlink ----------------------------------- + + def test_copies_package_on_windows_instead_of_symlinking(self, tmp_path): + """On Windows, packages are copied rather than symlinked (Windows doesn't support posix symlinks).""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) + site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) + (site_pkgs / "torch" / "version.py").write_text("__version__ = '2.10.0'") + py = str(tmp_path / "env" / "bin" / "python") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=True), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(site_pkgs))), + ): + _repoint_prebundle_packages() + + torch_in_prebundle = prebundle / "torch" + assert torch_in_prebundle.is_dir(), "torch should be a directory (copy) on Windows" + assert not torch_in_prebundle.is_symlink(), "torch must not be a symlink on Windows" + assert (torch_in_prebundle / "version.py").exists(), "Copied file should be present" + + # ---- error handling ----------------------------------------------------- + + def test_oserror_on_one_package_does_not_abort_others(self, tmp_path): + """An OSError while repointing one package is logged and processing continues for others.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch", "torchvision"]) + site_pkgs = _make_site_packages(tmp_path / "env", ["torch", "torchvision"]) + py = str(tmp_path / "env" / "bin" / "python") + + original_symlink_to = Path.symlink_to + call_count: list[int] = [0] + + def _selective_symlink(self_path: Path, target: Path, **kwargs) -> None: + call_count[0] += 1 + # Fail on the first symlink_to call (torch) but succeed for others. + if call_count[0] == 1: + raise OSError("Permission denied") + return original_symlink_to(self_path, target, **kwargs) + + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(site_pkgs))), + mock.patch.object(Path, "symlink_to", _selective_symlink), + ): + _repoint_prebundle_packages() # must not raise + + # torchvision (second package) must still be repointed despite torch failure. + assert (prebundle / "torchvision").is_symlink(), "torchvision must succeed after torch OSError" + + def test_skips_gracefully_when_site_packages_probe_fails(self, tmp_path): + """When the site-packages probe subprocess fails, _repoint_prebundle_packages is a no-op.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) + py = str(tmp_path / "python") + + with ( + mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), + mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), + # Probe subprocess exits non-zero + mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(returncode=1, stdout="")), + ): + _repoint_prebundle_packages() + + assert not (prebundle / "torch").is_symlink(), "No symlink should be created when probe fails" + + # ---- all packages in the repoint list are covered ----------------------- + + @pytest.mark.parametrize("pkg_name", [p for p in _PREBUNDLE_REPOINT_PACKAGES if p != "nvidia"]) + def test_all_non_nvidia_packages_are_repointed(self, tmp_path, pkg_name): + """Every non-nvidia entry in _PREBUNDLE_REPOINT_PACKAGES is repointed when it exists.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", [pkg_name]) + site_pkgs = _make_site_packages(tmp_path / "env", [pkg_name]) + py = str(tmp_path / "env" / "bin" / "python") + + with self._patch(isaacsim_path, site_pkgs, py): + _repoint_prebundle_packages() + + assert (prebundle / pkg_name).is_symlink(), f"{pkg_name} should be repointed" + assert (prebundle / pkg_name).resolve() == (site_pkgs / pkg_name).resolve() diff --git a/source/isaaclab/test/cli/test_install_prebundle.py b/source/isaaclab/test/cli/test_install_prebundle.py new file mode 100644 index 000000000000..ef08d0a1a726 --- /dev/null +++ b/source/isaaclab/test/cli/test_install_prebundle.py @@ -0,0 +1,81 @@ +# 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 + +"""Tests for prebundle probe and _split_install_items. + +Supplements test_install_commands.py with tests that verify the probe +script text and the comma-separated install item parser. +""" + +from unittest import mock + +from isaaclab.cli.commands.install import ( + _split_install_items, + _torch_first_on_sys_path_is_prebundle, +) + +# --------------------------------------------------------------------------- +# _split_install_items +# --------------------------------------------------------------------------- + + +class TestSplitInstallItems: + """Tests for :func:`_split_install_items`.""" + + def test_single_item(self): + assert _split_install_items("assets") == ["assets"] + + def test_comma_separated(self): + assert _split_install_items("assets,tasks,rl") == ["assets", "tasks", "rl"] + + def test_with_spaces(self): + assert _split_install_items(" assets , tasks , rl ") == ["assets", "tasks", "rl"] + + def test_brackets_preserved(self): + """Commas inside brackets should not split.""" + assert _split_install_items("visualizers[rerun,newton],tasks") == [ + "visualizers[rerun,newton]", + "tasks", + ] + + def test_nested_brackets(self): + assert _split_install_items("a[b[c,d],e],f") == ["a[b[c,d],e]", "f"] + + def test_empty_string(self): + assert _split_install_items("") == [] + + def test_trailing_comma(self): + assert _split_install_items("assets,tasks,") == ["assets", "tasks"] + + def test_single_with_extra(self): + assert _split_install_items("visualizers[all]") == ["visualizers[all]"] + + +# --------------------------------------------------------------------------- +# _torch_first_on_sys_path_is_prebundle — probe script verification +# --------------------------------------------------------------------------- + + +class TestTorchProbeScriptContent: + """Verify that the probe script checks for 'pip_prebundle' not 'extsDeprecated'.""" + + def test_probe_script_checks_pip_prebundle(self): + """The inline Python probe must use 'pip_prebundle' as its path indicator.""" + import subprocess + + captured_cmd = None + + def fake_run(cmd, *, env=None, check=False, capture_output=False, text=False): + nonlocal captured_cmd + captured_cmd = cmd + return subprocess.CompletedProcess(args=cmd, returncode=0) + + with mock.patch("isaaclab.cli.commands.install.run_command", side_effect=fake_run): + _torch_first_on_sys_path_is_prebundle("/fake/python", env={}) + + assert captured_cmd is not None + probe_script = captured_cmd[2] # [python_exe, "-c", probe] + assert "pip_prebundle" in probe_script, "Probe must check for 'pip_prebundle'" + assert "extsDeprecated" not in probe_script, "Probe must NOT check only for 'extsDeprecated'" diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 4c44bfd00ba5..d738f179da71 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -194,8 +194,7 @@ class SampleGroupCfg(ObservationGroupCfg): obs_man_str_split = obs_man_str.split("|") term_1_str_index = obs_man_str_split.index(" term_1 ") term_1_str_shape = obs_man_str_split[term_1_str_index + 1].strip() - # Handle numpy 2.0 where shape may be represented as (np.int64(20),) instead of (20,) - assert term_1_str_shape in ("(20,)", "(np.int64(20),)") + assert term_1_str_shape == "(20,)" def test_config_equivalence(setup_env): diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index ebc183b804b8..906c14ccb7d5 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -126,6 +126,7 @@ def test_multiple_prototypes_marker(sim): sim.step() +@pytest.mark.flaky(max_runs=3, min_passes=1) def test_visualization_time_based_on_prototypes(sim): """Test with time taken when number of prototypes is increased.""" # create a marker diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index f76a109c35d2..bb81b4f2779d 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -35,8 +35,8 @@ ({"name": "Cartpole", "robot_cfg": CARTPOLE_CFG, "expected_load_time": 10.0}, "cuda:0"), ({"name": "Cartpole", "robot_cfg": CARTPOLE_CFG, "expected_load_time": 10.0}, "cpu"), # TODO: regression - this used to be 40 - ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 50.0}, "cuda:0"), - ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 50.0}, "cpu"), + ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 55.0}, "cuda:0"), + ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 55.0}, "cpu"), ], ) def test_robot_load_performance(test_config, device): diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index ddd07c9f5617..65c697029f97 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -12,16 +12,15 @@ """Rest everything follows.""" +import math import os import tempfile import warnings import xml.etree.ElementTree as ET -import numpy as np import pytest import omni.kit.app -from isaacsim.core.prims import Articulation import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -110,43 +109,62 @@ def test_create_prim_from_usd(sim_config): @pytest.mark.isaacsim_ci def test_config_drive_type(sim_config): - """Change the drive mechanism of the robot to be position.""" + """Verify that ``target_type='position'`` plus uniform PD gains are written into every joint's DriveAPI. + + Reads the converter's USD output directly via :class:`pxr.UsdPhysics.DriveAPI` so the assertion does + not depend on a running PhysX simulation. Revolute joints are checked in N·m/deg (the USD storage + convention) and prismatic joints in N/m. + """ sim, config = sim_config - # Create directory to dump results test_dir = os.path.dirname(os.path.abspath(__file__)) output_dir = os.path.join(test_dir, "output", "urdf_converter") - if not os.path.exists(output_dir): - os.makedirs(output_dir, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) + + stiffness = 42.0 + damping = 4.2 - # change the config config.force_usd_conversion = True config.joint_drive.target_type = "position" - config.joint_drive.gains.stiffness = 42.0 - config.joint_drive.gains.damping = 4.2 + config.joint_drive.gains.stiffness = stiffness + config.joint_drive.gains.damping = damping config.usd_dir = output_dir urdf_converter = UrdfConverter(config) - # check the drive type of the robot - prim_path = "/World/Robot" - sim_utils.create_prim(prim_path, usd_path=urdf_converter.usd_path) - # access the robot - robot = Articulation(prim_path, reset_xform_properties=False) - # play the simulator and initialize the robot - sim.reset() - robot.initialize() + from pxr import Usd, UsdPhysics - # check drive values for the robot (read from physx) - drive_stiffness, drive_damping = robot.get_gains() - np.testing.assert_allclose(drive_stiffness.cpu().numpy(), config.joint_drive.gains.stiffness) - np.testing.assert_allclose(drive_damping.cpu().numpy(), config.joint_drive.gains.damping) + stage = Usd.Stage.Open(urdf_converter.usd_path) - # check drive values for the robot (read from usd) - # Note: Disable the app control callback to prevent hanging during sim.stop() - sim._disable_app_control_on_stop_handle = True - sim.stop() - drive_stiffness, drive_damping = robot.get_gains() - np.testing.assert_allclose(drive_stiffness.cpu().numpy(), config.joint_drive.gains.stiffness) - np.testing.assert_allclose(drive_damping.cpu().numpy(), config.joint_drive.gains.damping) + revolute_count = 0 + prismatic_count = 0 + for prim in stage.Traverse(): + is_revolute = prim.IsA(UsdPhysics.RevoluteJoint) + is_prismatic = prim.IsA(UsdPhysics.PrismaticJoint) + if not (is_revolute or is_prismatic): + continue + instance_name = "angular" if is_revolute else "linear" + drive = UsdPhysics.DriveAPI.Get(prim, instance_name) + actual_stiffness = drive.GetStiffnessAttr().Get() + actual_damping = drive.GetDampingAttr().Get() + + if is_revolute: + expected_stiffness = stiffness * math.pi / 180.0 + expected_damping = damping * math.pi / 180.0 + revolute_count += 1 + else: + expected_stiffness = stiffness + expected_damping = damping + prismatic_count += 1 + + assert abs(actual_stiffness - expected_stiffness) < 1e-4, ( + f"Joint {prim.GetName()}: expected stiffness {expected_stiffness}, got {actual_stiffness}" + ) + assert abs(actual_damping - expected_damping) < 1e-4, ( + f"Joint {prim.GetName()}: expected damping {expected_damping}, got {actual_damping}" + ) + + # Franka Panda has 7 revolute arm joints and 2 prismatic finger joints. + assert revolute_count == 7, f"Expected 7 revolute joints, got {revolute_count}" + assert prismatic_count == 2, f"Expected 2 prismatic joints, got {prismatic_count}" @pytest.mark.isaacsim_ci @@ -405,7 +423,7 @@ def test_drive_type_acceleration(sim_config): @pytest.mark.isaacsim_ci def test_target_type_none_zeros_gains(sim_config): - """Verify that target_type='none' sets stiffness and damping to 0.""" + """Verify that ``target_type='none'`` zeros the DriveAPI stiffness and damping on every joint.""" sim, config = sim_config test_dir = os.path.dirname(os.path.abspath(__file__)) output_dir = os.path.join(test_dir, "output", "urdf_target_none") @@ -416,15 +434,27 @@ def test_target_type_none_zeros_gains(sim_config): config.usd_dir = output_dir urdf_converter = UrdfConverter(config) - prim_path = "/World/Robot" - sim_utils.create_prim(prim_path, usd_path=urdf_converter.usd_path) - robot = Articulation(prim_path, reset_xform_properties=False) - sim.reset() - robot.initialize() + from pxr import Usd, UsdPhysics - drive_stiffness, drive_damping = robot.get_gains() - np.testing.assert_allclose(drive_stiffness.cpu().numpy(), 0.0, atol=1e-6) - np.testing.assert_allclose(drive_damping.cpu().numpy(), 0.0, atol=1e-6) + stage = Usd.Stage.Open(urdf_converter.usd_path) + + joint_count = 0 + for prim in stage.Traverse(): + is_revolute = prim.IsA(UsdPhysics.RevoluteJoint) + is_prismatic = prim.IsA(UsdPhysics.PrismaticJoint) + if not (is_revolute or is_prismatic): + continue + instance_name = "angular" if is_revolute else "linear" + drive = UsdPhysics.DriveAPI.Get(prim, instance_name) + assert abs(drive.GetStiffnessAttr().Get()) < 1e-6, ( + f"Joint {prim.GetName()}: expected zero stiffness, got {drive.GetStiffnessAttr().Get()}" + ) + assert abs(drive.GetDampingAttr().Get()) < 1e-6, ( + f"Joint {prim.GetName()}: expected zero damping, got {drive.GetDampingAttr().Get()}" + ) + joint_count += 1 + + assert joint_count > 0, "No joints found in the output USD" @pytest.mark.isaacsim_ci @@ -474,8 +504,6 @@ def test_per_joint_dict_gains(sim_config): if "panda_joint" in name and "finger" not in name: # arm joint (revolute) — USD stores in Nm/deg, so expected = value * pi/180 - import math - expected_s = arm_stiffness * math.pi / 180.0 expected_d = arm_damping * math.pi / 180.0 assert abs(stiffness_attr.Get() - expected_s) < 0.01, ( diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index ec1f45bec732..0722f9c9a05a 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -37,6 +37,9 @@ Changed :class:`~isaaclab_newton.assets.RigidObject`, and :class:`~isaaclab_newton.assets.RigidObjectCollection` to use the dual-buffer :class:`~isaaclab.utils.wrench_composer.WrenchComposer`. Composed wrenches are applied after body-frame composition. +* Updated the PhysX Tensor API docstring link in :class:`~isaaclab_newton.assets.ArticulationData` + from ``omni.physics.tensors.impl.api`` to ``omni.physics.tensors.api`` to track the upstream + Isaac Sim module relocation (the ``impl`` submodule was removed). 0.5.18 (2026-04-21) diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index 4dc9507dbe59..0c4cdc68a353 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -803,7 +803,7 @@ def body_incoming_joint_wrench_b(self) -> wp.array: underlying `PhysX Tensor API`_. .. _PhysX documentation: https://nvidia-omniverse.github.io/PhysX/physx/5.5.1/docs/Articulations.html#link-incoming-joint-force - .. _PhysX Tensor API: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.impl.api.ArticulationView.get_link_incoming_joint_force + .. _PhysX Tensor API: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.api.ArticulationView.get_link_incoming_joint_force """ raise NotImplementedError("Not implemented for Newton") diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 5f4fb7f10cd8..39a9b2dbe4a9 100644 --- a/source/isaaclab_physx/config/extension.toml +++ b/source/isaaclab_physx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.22" +version = "0.5.25" # Description title = "PhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 566890bda9f2..6f9bcbe733f6 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,7 +1,33 @@ Changelog --------- -0.5.22 (2026-04-23) +0.5.25 (2026-04-24) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed ``import isaaclab_physx`` eagerly importing ``isaacsim``, ``omni``, + and ``carb`` backend modules when used for pure-data config loading before + ``SimulationApp`` has launched. The ``SimulationManager`` patch now checks + ``sys.modules`` lazily instead of force-importing the target module, allowing + env-cfg classes that reference :class:`~isaaclab_physx.physics.PhysxCfg` to + be constructed without a running Kit instance (regression caught by + ``test_env_cfg_no_forbidden_imports``). + + +0.5.24 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Updated imports of the PhysX tensors API from ``omni.physics.tensors.impl.api`` to + ``omni.physics.tensors.api`` to track the upstream Isaac Sim module relocation + (the ``impl`` submodule was removed). + + +0.5.23 (2026-04-23) ~~~~~~~~~~~~~~~~~~~ Fixed @@ -15,7 +41,7 @@ Fixed the freshly built artifact on the simulation context so subsequent providers reuse it. -0.5.21 (2026-04-22) +0.5.22 (2026-04-22) ~~~~~~~~~~~~~~~~~~~ Added @@ -31,6 +57,26 @@ Changed :class:`~isaaclab_physx.sim.views.FabricFrameView`. Old name is kept as a deprecated alias. +0.5.21 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed ``Simulation view object is invalidated and cannot be used again to call + getDofVelocities`` raised on the first ``scene.update()`` after ``sim.reset()`` + with recent Isaac Sim ``develop`` builds. Isaac Sim's + ``isaacsim.core.simulation_manager.SimulationManager`` recently became reactive + to timeline ``STOP`` events (after its ``_on_stop`` was decorated with + ``@staticmethod`` upstream), and its ``invalidate_physics()`` was clobbering + the shared ``omni.physics.tensors`` simulation view that + :class:`~isaaclab_physx.physics.PhysxManager` and PhysX articulation views + rely on. The ``isaaclab_physx`` package init now disables the original Isaac + Sim ``SimulationManager``'s default timeline/stage callbacks via + ``enable_all_default_callbacks(False)`` before swapping the module attribute, + so :class:`PhysxManager` is the single owner of the simulation lifecycle. + + 0.5.20 (2026-04-21) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/isaaclab_physx/__init__.py b/source/isaaclab_physx/isaaclab_physx/__init__.py index a9f5f5bf9d94..b7bfc7481300 100644 --- a/source/isaaclab_physx/isaaclab_physx/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/__init__.py @@ -21,17 +21,79 @@ def _patch_isaacsim_simulation_manager(): - """Patch Isaac Sim's SimulationManager to use PhysxManager. + """Patch Isaac Sim's ``SimulationManager`` to use :class:`PhysxManager`. - This ensures all code that imports from isaacsim.core.simulation_manager - will use our PhysxManager instead, preventing duplicate callback registration. + This redirects future ``from isaacsim.core.simulation_manager import SimulationManager`` + consumers to :class:`isaaclab_physx.physics.PhysxManager`, but the original + Isaac Sim ``SimulationManager`` class has *already* registered timeline + (PLAY/STOP) and stage (OPENED/CLOSED) subscriptions during its extension + startup. Those subscriptions live on the original class, not the module + attribute, so swapping the attribute alone is not enough. + + Starting with Isaac Sim 6.0.0-alpha.180 (commit ``8df6beeb0`` on + ``develop``, "hmazhar/autofix_bugs"), the original + ``SimulationManager._on_stop``/``_on_play``/``_on_stage_*`` methods were + decorated with ``@staticmethod`` so they finally fire correctly from the + Carb event subscriptions. Before that fix they were silently broken (the + subscriptions invoked them as bound methods, so the ``event`` argument was + being passed as ``self``/``cls`` and the bodies never executed). + + The newly-working ``_on_stop`` calls + ``SimulationManager.invalidate_physics()``, which calls + ``view.invalidate()`` on its ``omni.physics.tensors`` simulation view. + Because ``omni.physics.tensors.create_simulation_view("warp", stage_id=...)`` + returns the same underlying SimulationView per stage_id, that invalidation + also wrecks the view that :class:`PhysxManager` (and any articulation + ``_root_view`` derived from it) relies on. The result is the runtime error + ``Simulation view object is invalidated and cannot be used again to call + getDofVelocities`` on the very first ``scene.update()`` after + ``sim.reset()``. + + To prevent this, we disable the original class's default callbacks here + *before* swapping the module attribute, so :class:`PhysxManager` becomes + the single owner of the simulation lifecycle. + + This function is intentionally lazy: it only patches if + ``isaacsim.core.simulation_manager`` is already present in ``sys.modules``. + In the normal production flow Kit loads that module during extension startup, + before any user script imports :mod:`isaaclab_physx`, so the condition is + true and the patch fires on time. If :mod:`isaaclab_physx` happens to be + imported for pure config loading before Kit has launched (e.g. in + ``test_env_cfg_no_forbidden_imports``), the module is absent and this + function is a no-op — which is correct, because no callbacks have been + registered yet. """ - if "isaacsim.core.simulation_manager" in sys.modules: - original_module = sys.modules["isaacsim.core.simulation_manager"] - from .physics.physx_manager import PhysxManager, IsaacEvents + original_module = sys.modules.get("isaacsim.core.simulation_manager") + if original_module is None: + return + + from .physics.physx_manager import IsaacEvents, PhysxManager + + # Tear down the original Isaac Sim SimulationManager's default timeline / + # stage subscriptions so they cannot invalidate the omni.physics.tensors + # view that PhysxManager owns. ``enable_all_default_callbacks(False)`` + # covers warm_start (PLAY), on_stop (STOP), stage_open (OPENED) and + # stage_close (CLOSED). Older Isaac Sim builds may not expose this API, so + # fall back gracefully. + original_class = getattr(original_module, "SimulationManager", None) + if original_class is not None and original_class is not PhysxManager: + try: + original_class.enable_all_default_callbacks(False) + except Exception: + # Defensive: API changed or original class never finished startup. + # Manually clear the subscription handles if they exist so any + # remaining references go through the dead-callback path. + for attr in ( + "_default_callback_warm_start", + "_default_callback_on_stop", + "_default_callback_stage_open", + "_default_callback_stage_close", + ): + if hasattr(original_class, attr): + setattr(original_class, attr, None) - original_module.SimulationManager = PhysxManager - original_module.IsaacEvents = IsaacEvents + original_module.SimulationManager = PhysxManager + original_module.IsaacEvents = IsaacEvents _patch_isaacsim_simulation_manager() diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index 3b403ee8c6d4..8ed288a4d9d2 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -35,7 +35,7 @@ from .articulation_data import ArticulationData if TYPE_CHECKING: - import omni.physics.tensors.impl.api as physx + import omni.physics.tensors.api as physx from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py index 42190a28f473..b9955c042785 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py @@ -21,7 +21,7 @@ from isaaclab_physx.physics import PhysxManager as SimulationManager if TYPE_CHECKING: - import omni.physics.tensors.impl.api as physx + import omni.physics.tensors.api as physx # import logger logger = logging.getLogger(__name__) @@ -763,7 +763,7 @@ def body_incoming_joint_wrench_b(self) -> wp.array: `PhysX Tensor API`_. .. _`PhysX documentation`: https://nvidia-omniverse.github.io/PhysX/physx/5.5.1/docs/Articulations.html#link-incoming-joint-force - .. _`PhysX Tensor API`: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.impl.api.ArticulationView.get_link_incoming_joint_force + .. _`PhysX Tensor API`: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.api.ArticulationView.get_link_incoming_joint_force """ if self._body_incoming_joint_wrench_b.timestamp < self._sim_timestamp: diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py index 1fb6a396c562..3bc6ed2b8b25 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py @@ -14,7 +14,7 @@ import torch import warp as wp -import omni.physics.tensors.impl.api as physx +import omni.physics.tensors.api as physx from pxr import UsdShade import isaaclab.sim as sim_utils diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py index 9aa38ff7778d..ccac2cf05b71 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py @@ -7,7 +7,7 @@ import warp as wp -import omni.physics.tensors.impl.api as physx +import omni.physics.tensors.api as physx from isaaclab.utils.buffers import TimestampedBufferWarp as TimestampedBuffer diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py index 8aa7dbd3f4f3..25abb8fd22a3 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py @@ -26,7 +26,7 @@ from .rigid_object_data import RigidObjectData if TYPE_CHECKING: - import omni.physics.tensors.impl.api as physx + import omni.physics.tensors.api as physx from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object_data.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object_data.py index 0a6157585c59..2bd71b799b7e 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object_data.py @@ -20,7 +20,7 @@ from isaaclab_physx.physics import PhysxManager as SimulationManager if TYPE_CHECKING: - import omni.physics.tensors.impl.api as physx + import omni.physics.tensors.api as physx # import logger logger = logging.getLogger(__name__) diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py index 3518aceac1d9..9d4d6b26979f 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py @@ -15,7 +15,7 @@ import torch import warp as wp -import omni.physics.tensors.impl.api as physx +import omni.physics.tensors.api as physx from pxr import UsdPhysics import isaaclab.sim as sim_utils diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection_data.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection_data.py index a0bdec5af8c9..dbb3b8523897 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection_data.py @@ -20,7 +20,7 @@ from isaaclab_physx.physics import PhysxManager as SimulationManager if TYPE_CHECKING: - import omni.physics.tensors.impl.api as physx + import omni.physics.tensors.api as physx # import logger logger = logging.getLogger(__name__) diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py index e4cd2e97429f..300898ab765d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py @@ -14,7 +14,7 @@ import torch import warp as wp -import omni.physics.tensors.impl.api as physx +import omni.physics.tensors.api as physx import isaaclab.sim as sim_utils from isaaclab.app.settings_manager import get_settings_manager diff --git a/source/isaaclab_tasks/test/test_rendering_correctness.py b/source/isaaclab_tasks/test/test_rendering_correctness.py index 026a72c4e086..b87371e3a300 100644 --- a/source/isaaclab_tasks/test/test_rendering_correctness.py +++ b/source/isaaclab_tasks/test/test_rendering_correctness.py @@ -847,6 +847,7 @@ def dexsuite_kuka_allegro_lift_env(request): env.close() +@pytest.mark.flaky(max_runs=3, min_passes=1) def test_dexsuite_kuka_allegro_lift(dexsuite_kuka_allegro_lift_env): """Camera output must contain at least one non-zero pixel (Dexsuite Kuka-Allegro Lift, single camera).""" physics_backend, renderer, _, env = dexsuite_kuka_allegro_lift_env diff --git a/tools/test_settings.py b/tools/test_settings.py index 0db4f805b16a..a18531701bb2 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -59,6 +59,7 @@ "test_multirotor.py": 1000, "test_shadow_hand_vision_presets.py": 5000, "test_environments_newton.py": 5000, + "test_surface_gripper.py": 3000, } """A dictionary of tests and their timeouts in seconds.