From 22fe4f7575f66c1fa522c4874bd115da1cb3e45e Mon Sep 17 00:00:00 2001 From: Mustafa Haiderbhai Date: Sat, 29 Aug 2026 13:42:49 -0700 Subject: [PATCH 1/5] Flatten the installed Isaac Lab package --- .../changelog.d/flatten-wheel-package.rst | 5 + source/isaaclab/isaaclab/_paths.py | 25 ++++ source/isaaclab/isaaclab/app/app_launcher.py | 3 +- .../isaaclab/benchmark/microbenchmark.py | 3 +- .../recorders/record_version_info.py | 5 +- source/isaaclab/isaaclab/cli/utils.py | 3 +- source/isaaclab/isaaclab/utils/assets.py | 6 +- .../misc/test_wheel_builder_smoke.py | 22 ++++ tools/wheel_builder/build.sh | 10 +- tools/wheel_builder/gen_pyproject.py | 2 +- tools/wheel_builder/res/__init__.py | 116 ------------------ 11 files changed, 72 insertions(+), 128 deletions(-) create mode 100644 source/isaaclab/changelog.d/flatten-wheel-package.rst create mode 100644 source/isaaclab/isaaclab/_paths.py delete mode 100644 tools/wheel_builder/res/__init__.py diff --git a/source/isaaclab/changelog.d/flatten-wheel-package.rst b/source/isaaclab/changelog.d/flatten-wheel-package.rst new file mode 100644 index 000000000000..6a090515e8c0 --- /dev/null +++ b/source/isaaclab/changelog.d/flatten-wheel-package.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the Isaac Lab wheel to install core modules in a flat ``isaaclab`` package instead of + exposing a nested source package through a modified package search path. diff --git a/source/isaaclab/isaaclab/_paths.py b/source/isaaclab/isaaclab/_paths.py new file mode 100644 index 000000000000..2dd9fc68fd58 --- /dev/null +++ b/source/isaaclab/isaaclab/_paths.py @@ -0,0 +1,25 @@ +# 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 + +"""Resolve paths shared by source checkouts and installed wheels.""" + +from pathlib import Path + + +def _resolve_isaaclab_root() -> Path: + """Return the directory containing Isaac Lab runtime resources.""" + package_root = Path(__file__).resolve().parent + if (package_root / "apps").is_dir(): + return package_root + + for parent in package_root.parents: + if (parent / "apps").is_dir() and (parent / "source" / "isaaclab").is_dir(): + return parent + + raise RuntimeError(f"Could not locate the Isaac Lab root from {package_root}") + + +ISAACLAB_ROOT = _resolve_isaaclab_root() +"""Directory containing Isaac Lab runtime resources.""" diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index cca32d90a3de..0ae51f9d1b87 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -33,6 +33,7 @@ SimulationApp = getattr(isaacsim, "SimulationApp", None) +from isaaclab._paths import ISAACLAB_ROOT from isaaclab.app.loading_screen import report_activity from isaaclab.app.logging_utils import apply_python_logging_level, resolve_python_logging_level from isaaclab.app.settings_manager import get_settings_manager, initialize_carb_settings @@ -1180,7 +1181,7 @@ def _resolve_experience_file(self, launcher_args: dict): ) from e kit_app_exp_path = os.path.join(os.path.dirname(_isaacsim_for_paths.__file__), "apps") os.environ["EXP_PATH"] = kit_app_exp_path - isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") + isaaclab_app_exp_path = str(ISAACLAB_ROOT / "apps") # For Isaac Sim 4.5 compatibility, we use the 4.5 app files in a different folder # if launcher_args.get("use_isaacsim_45", False): if self.is_isaac_sim_version_5(): diff --git a/source/isaaclab/isaaclab/benchmark/microbenchmark.py b/source/isaaclab/isaaclab/benchmark/microbenchmark.py index be0de73368d4..435cdcf09fc3 100644 --- a/source/isaaclab/isaaclab/benchmark/microbenchmark.py +++ b/source/isaaclab/isaaclab/benchmark/microbenchmark.py @@ -13,6 +13,7 @@ from pathlib import Path from types import MappingProxyType +from isaaclab._paths import ISAACLAB_ROOT from isaaclab.cli.utils import run_python_command @@ -62,7 +63,7 @@ def repository_root(cls) -> Path: Returns: Repository root containing the backend benchmark entrypoints. """ - return Path(__file__).parents[4] + return ISAACLAB_ROOT @classmethod def physics_variants(cls) -> tuple[str, ...]: diff --git a/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py b/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py index f5cc62d1e860..06de005571a1 100644 --- a/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py +++ b/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py @@ -8,11 +8,12 @@ import subprocess import sys +from isaaclab._paths import ISAACLAB_ROOT from isaaclab.benchmark.interfaces import MeasurementData, MeasurementDataRecorder from isaaclab.benchmark.measurements import DictMetadata, StringMetadata -# Path to the repository root. -_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 6)) +# Path to the source checkout or installed wheel resources. +_REPO_ROOT = str(ISAACLAB_ROOT) class VersionInfoRecorder(MeasurementDataRecorder): diff --git a/source/isaaclab/isaaclab/cli/utils.py b/source/isaaclab/isaaclab/cli/utils.py index 11f15432c05b..6517242d9597 100644 --- a/source/isaaclab/isaaclab/cli/utils.py +++ b/source/isaaclab/isaaclab/cli/utils.py @@ -13,8 +13,7 @@ from pathlib import Path from typing import IO, Any -# Path to Isaac Lab installation. -ISAACLAB_ROOT = Path(__file__).parents[4].resolve() +from isaaclab._paths import ISAACLAB_ROOT # Default path to look for Isaac Sim is _isaac_sim symlink. DEFAULT_ISAAC_SIM_PATH = ISAACLAB_ROOT / "_isaac_sim" diff --git a/source/isaaclab/isaaclab/utils/assets.py b/source/isaaclab/isaaclab/utils/assets.py index d75012822fce..4e65471aacbc 100644 --- a/source/isaaclab/isaaclab/utils/assets.py +++ b/source/isaaclab/isaaclab/utils/assets.py @@ -29,6 +29,8 @@ from filelock import FileLock +from isaaclab._paths import ISAACLAB_ROOT + logger = logging.getLogger(__name__) _UDIM_RE = re.compile(r"", re.IGNORECASE) @@ -42,9 +44,7 @@ ) -_KIT_EXPERIENCE_PATH = os.path.normpath( - os.path.join(os.path.dirname(__file__), *([".."] * 4), "apps", "isaaclab.python.kit") -) +_KIT_EXPERIENCE_PATH = str(ISAACLAB_ROOT / "apps" / "isaaclab.python.kit") # Isaac Sim resolves ``persistent.isaac.asset_root.default``, so it is read first. The # legacy ``cloud`` setting is only consulted for experience files that predate it. diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index 59b56d3d1f7b..b6d8ff993003 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -11,6 +11,7 @@ Tests: - import isaaclab -> verify importable - from isaaclab import __version__ -> verify version matches wheel filename + - inspect the wheel and isaaclab.__path__ -> verify the core package has a flat layout - from isaaclab import _deprioritize_prebundle_paths -> verify wheel exports path sanitizer - from isaaclab.app import AppLauncher -> verify importable - from isaaclab.envs import VideoRecorderCfg -> verify importable @@ -27,6 +28,7 @@ import glob import shutil +import zipfile import pytest from utils import UV_Mixin, run_cmd @@ -92,6 +94,26 @@ def test_isaaclab_version_matches_wheel(self): f"isaaclab.__version__ mismatch: expected {expected_version}, got {imported_version}" ) + def test_isaaclab_package_has_flat_layout(self): + """Verify core modules are installed directly under the top-level package.""" + with zipfile.ZipFile(self._wheel) as wheel: + names = set(wheel.namelist()) + + assert "isaaclab/app/__init__.py" in names + nested_prefix = "isaaclab/source/isaaclab/isaaclab/" + assert not any(name.startswith(nested_prefix) for name in names) + + result = self.run_in_uv_env( + [ + "python", + "-c", + "import isaaclab; " + "from pathlib import Path; " + "assert list(isaaclab.__path__) == [str(Path(isaaclab.__file__).parent)]", + ] + ) + assert result.returncode == 0, f"isaaclab has multiple package roots:\n{result.stdout}\n{result.stderr}" + # from isaaclab import _deprioritize_prebundle_paths def test_isaaclab_prebundle_path_sanitizer_exported(self): """Verify the wheel exports the prebundle path sanitizer used by AppLauncher.""" diff --git a/tools/wheel_builder/build.sh b/tools/wheel_builder/build.sh index 1bb7c2d8c95b..981d85d611b3 100755 --- a/tools/wheel_builder/build.sh +++ b/tools/wheel_builder/build.sh @@ -46,6 +46,13 @@ cp -r tools/template "$BUILD_DIR/src/isaaclab/tools/" # Ensure apps/ is discovered as a Python sub-package (it has no __init__.py) find "$BUILD_DIR/src/isaaclab/apps" -type d -exec touch {}/__init__.py \; +# Install the core Python package in the conventional flat layout. Runtime resources +# remain under isaaclab/apps, isaaclab/source, and isaaclab/tools, but imports such as +# isaaclab.app resolve directly without extending isaaclab.__path__ at runtime. +CORE_PACKAGE="$BUILD_DIR/src/isaaclab/source/isaaclab/isaaclab" +cp -r "$CORE_PACKAGE/." "$BUILD_DIR/src/isaaclab/" +rm -rf "$CORE_PACKAGE" + # Promote sub-packages (isaaclab_assets, isaaclab_rl, etc.) to top-level # so they are importable as e.g. `import isaaclab_assets`. # Each extension has the structure: source/isaaclab_FOO/isaaclab_FOO/ (Python pkg) @@ -83,8 +90,7 @@ find "$BUILD_DIR/src" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null find "$BUILD_DIR/src" -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true find "$BUILD_DIR/src" -name "*.pyc" -delete 2>/dev/null || true -# 2. Copy the custom res __init__.py and __main__.py -cp "$SELF_DIR/res/__init__.py" "$BUILD_DIR/src/isaaclab/" +# 2. Copy the custom __main__.py used by ``python -m isaaclab`` cp "$SELF_DIR/res/__main__.py" "$BUILD_DIR/src/isaaclab/" # 3. Generate pyproject.toml with dependencies from the root pyproject.toml diff --git a/tools/wheel_builder/gen_pyproject.py b/tools/wheel_builder/gen_pyproject.py index b8a619597ec6..53e48f8fab49 100644 --- a/tools/wheel_builder/gen_pyproject.py +++ b/tools/wheel_builder/gen_pyproject.py @@ -124,7 +124,7 @@ def _dedup(requirements: list[str]) -> list[str]: lines.append("]") lines.append("") lines.append("[project.scripts]") -lines.append('isaaclab = "isaaclab:main"') +lines.append('isaaclab = "isaaclab.cli:cli"') lines.append("") lines.append("[project.optional-dependencies]") for name, dep_list in opt_deps.items(): diff --git a/tools/wheel_builder/res/__init__.py b/tools/wheel_builder/res/__init__.py deleted file mode 100644 index 0040b3aa3ce9..000000000000 --- a/tools/wheel_builder/res/__init__.py +++ /dev/null @@ -1,116 +0,0 @@ -# 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 - -import os -import sys -from importlib.metadata import version -from importlib.util import find_spec - -__version__ = version("isaaclab") - -# Extend the package search path so subpackages (app/, envs/, etc.) in the -# nested source tree are importable as isaaclab.app, isaaclab.envs, etc. -__path__.append(os.path.join(os.path.dirname(__file__), "source", "isaaclab", "isaaclab")) - - -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() - - -# TODO(myurasov-nv): bootstrap_kernel() is ported from the internal GitLab wheel builder -# for backwards compatibility. It is not called currently, but may be needed if Isaac Sim -# requires explicit kernel bootstrapping before use. Remove once confirmed unnecessary. -def bootstrap_kernel(): - # Isaac Lab path - isaaclab_path = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) - - # bootstrap kernel via Isaac Sim - if find_spec("isaacsim") is not None: - import isaacsim - - # log info - if find_spec("carb") is not None: - import carb - carb.log_info(f"Isaac Lab path: {isaaclab_path}") - - -def main(): - """Entry point for the ``isaaclab`` console script (python -m isaaclab).""" - from isaaclab.__main__ import main as _main - - sys.exit(_main()) - - -if __name__ == "__main__": - bootstrap_kernel() - main() From 1e55ede3a6d37136d93141b8c8d81c7d3ed20a51 Mon Sep 17 00:00:00 2001 From: Mustafa Haiderbhai Date: Sat, 29 Aug 2026 14:46:15 -0700 Subject: [PATCH 2/5] Support partial source checkouts --- source/isaaclab/isaaclab/_paths.py | 2 +- .../test/cli/test_installed_workflow_entrypoints.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/source/isaaclab/isaaclab/_paths.py b/source/isaaclab/isaaclab/_paths.py index 2dd9fc68fd58..5706465e81fd 100644 --- a/source/isaaclab/isaaclab/_paths.py +++ b/source/isaaclab/isaaclab/_paths.py @@ -15,7 +15,7 @@ def _resolve_isaaclab_root() -> Path: return package_root for parent in package_root.parents: - if (parent / "apps").is_dir() and (parent / "source" / "isaaclab").is_dir(): + if parent / "source" / "isaaclab" / "isaaclab" == package_root: return parent raise RuntimeError(f"Could not locate the Isaac Lab root from {package_root}") diff --git a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py index eea2b4f91575..ea161651cb0a 100644 --- a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py +++ b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py @@ -12,11 +12,21 @@ import pytest +import isaaclab._paths as paths import isaaclab.cli as cli pytestmark = pytest.mark.unit +def test_resolves_partial_source_checkout_root(tmp_path): + """Source root resolution must not require resources copied by later Docker layers.""" + package_root = tmp_path / "source" / "isaaclab" / "isaaclab" + package_root.mkdir(parents=True) + + with mock.patch.object(paths, "__file__", str(package_root / "_paths.py")): + assert paths._resolve_isaaclab_root() == tmp_path + + @pytest.mark.parametrize( ("command", "runner"), [ From 780b30c75a379e868e4e1f30c64262b6a29e9339 Mon Sep 17 00:00:00 2001 From: Mustafa Haiderbhai Date: Thu, 3 Sep 2026 13:38:51 -0700 Subject: [PATCH 3/5] Support aggregate installs from Git sources --- docs/source/setup/installation/index.rst | 18 ++++ .../isaaclab/changelog.d/git-source-wheel.rst | 5 + .../misc/test_wheel_builder_smoke.py | 21 +++-- tools/wheel_builder/build.sh | 61 +----------- tools/wheel_builder/build_backend.py | 68 ++++++++++++++ tools/wheel_builder/pyproject.toml | 4 + tools/wheel_builder/stage.py | 93 +++++++++++++++++++ 7 files changed, 205 insertions(+), 65 deletions(-) create mode 100644 source/isaaclab/changelog.d/git-source-wheel.rst create mode 100644 tools/wheel_builder/build_backend.py create mode 100644 tools/wheel_builder/pyproject.toml create mode 100644 tools/wheel_builder/stage.py diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index b5ffeb380677..e3976d93364b 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -568,6 +568,24 @@ To create a project built on Isaac Lab, see :ref:`template-generator`. Isaac Lab wheels are published for major releases, not every patch release. +Installing an unreleased Git revision +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The aggregate package can also be built directly from an Isaac Lab Git revision. Point uv at the +``tools/wheel_builder`` subdirectory so it uses the same dependency metadata and packaged runtime +resources as a released wheel: + +.. code-block:: toml + + [project] + dependencies = ["isaaclab"] + + [tool.uv.sources] + isaaclab = { git = "https://github.com/isaac-sim/IsaacLab.git", rev = "", subdirectory = "tools/wheel_builder" } + +Use a commit hash or release tag for reproducible environments. A branch name is accepted, but +updating the lockfile can then select a newer Isaac Lab revision and dependency set. + Choose how you want uv to manage the dependency. Both workflows start with the base ``isaaclab`` package; add optional capabilities only when your project needs them. diff --git a/source/isaaclab/changelog.d/git-source-wheel.rst b/source/isaaclab/changelog.d/git-source-wheel.rst new file mode 100644 index 000000000000..acbe206d3897 --- /dev/null +++ b/source/isaaclab/changelog.d/git-source-wheel.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added a PEP 517 build target under ``tools/wheel_builder`` so package managers can install the aggregate Isaac Lab + package directly from a Git source without duplicating Isaac Lab's dependency list downstream. diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index b6d8ff993003..6fa6e8b81870 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -5,7 +5,7 @@ """ Setup: - - bash tools/wheel_builder/build.sh + - uv build --wheel tools/wheel_builder --out-dir tools/wheel_builder/build/dist - ./isaaclab.sh -u - uv pip install [sb3,skrl,rsl-rl] Tests: @@ -51,13 +51,20 @@ def _build_and_install_wheel(self, isaaclab_root): """Build the wheel and install it in a uv environment once for all tests.""" cls = self.__class__ - build_script = isaaclab_root / "tools" / "wheel_builder" / "build.sh" - dist_dir = isaaclab_root / "tools" / "wheel_builder" / "build" / "dist" + builder_dir = isaaclab_root / "tools" / "wheel_builder" + dist_dir = builder_dir / "build" / "dist" + shutil.rmtree(dist_dir, ignore_errors=True) + dist_dir.mkdir(parents=True) - # Build the wheel (capture output silently to avoid spamming the test log with 10k+ + # Build through the PEP 517 entry point used by Git-source consumers. Capture output + # silently to avoid spamming the test log with 10k+ # setuptools/pip lines; the captured output is included in the assertion if it fails). - result = run_cmd(["bash", str(build_script)], cwd=isaaclab_root, stream=False) - assert result.returncode == 0, f"build.sh failed:\n{result.stdout}\n{result.stderr}" + result = run_cmd( + ["uv", "build", "--wheel", str(builder_dir), "--out-dir", str(dist_dir)], + cwd=isaaclab_root, + stream=False, + ) + assert result.returncode == 0, f"PEP 517 wheel build failed:\n{result.stdout}\n{result.stderr}" # Find the built wheel wheels = glob.glob(str(dist_dir / "isaaclab-*.whl")) @@ -100,6 +107,8 @@ def test_isaaclab_package_has_flat_layout(self): names = set(wheel.namelist()) assert "isaaclab/app/__init__.py" in names + assert "isaaclab/apps/isaaclab.python.kit" in names + assert "isaaclab/source/isaaclab_assets/config/extension.toml" in names nested_prefix = "isaaclab/source/isaaclab/isaaclab/" assert not any(name.startswith(nested_prefix) for name in names) diff --git a/tools/wheel_builder/build.sh b/tools/wheel_builder/build.sh index 981d85d611b3..859205a9a97f 100755 --- a/tools/wheel_builder/build.sh +++ b/tools/wheel_builder/build.sh @@ -35,66 +35,9 @@ case "$ARCH" in esac rm -rf "$BUILD_DIR" "$DIST_DIR" -mkdir -p "$BUILD_DIR/src/isaaclab" -# 1. Copy inventory (the full source tree: apps/ + source/) -cp -r apps "$BUILD_DIR/src/isaaclab/" -cp -r source "$BUILD_DIR/src/isaaclab/" -mkdir -p "$BUILD_DIR/src/isaaclab/tools" -cp -r tools/template "$BUILD_DIR/src/isaaclab/tools/" - -# Ensure apps/ is discovered as a Python sub-package (it has no __init__.py) -find "$BUILD_DIR/src/isaaclab/apps" -type d -exec touch {}/__init__.py \; - -# Install the core Python package in the conventional flat layout. Runtime resources -# remain under isaaclab/apps, isaaclab/source, and isaaclab/tools, but imports such as -# isaaclab.app resolve directly without extending isaaclab.__path__ at runtime. -CORE_PACKAGE="$BUILD_DIR/src/isaaclab/source/isaaclab/isaaclab" -cp -r "$CORE_PACKAGE/." "$BUILD_DIR/src/isaaclab/" -rm -rf "$CORE_PACKAGE" - -# Promote sub-packages (isaaclab_assets, isaaclab_rl, etc.) to top-level -# so they are importable as e.g. `import isaaclab_assets`. -# Each extension has the structure: source/isaaclab_FOO/isaaclab_FOO/ (Python pkg) -# plus sibling dirs like config/, data/. The __init__.py references ../config etc. -# We copy the inner Python package to src/ and also copy sibling resource dirs -# (config, data) into it so the relative-path lookups in __init__.py work. -for ext_dir in "$BUILD_DIR"/src/isaaclab/source/isaaclab_*; do - pkg=$(basename "$ext_dir") - inner="$ext_dir/$pkg" - if [ -d "$inner" ] && [ -f "$inner/__init__.py" ]; then - cp -r "$inner" "$BUILD_DIR/src/$pkg" - # Copy resource dirs (config/, data/) into the Python package - for res_dir in config data; do - if [ -d "$ext_dir/$res_dir" ]; then - cp -r "$ext_dir/$res_dir" "$BUILD_DIR/src/$pkg/$res_dir" - fi - done - # Patch EXT_DIR: change '../' to '.' so __init__.py finds config/ inside - # the package dir rather than one level up. - sed -i 's|os\.path\.join(os\.path\.dirname(__file__), "\.\./"|os.path.join(os.path.dirname(__file__), ""|g' \ - "$BUILD_DIR/src/$pkg/__init__.py" - # Keep the extension discoverable by Kit under source/. The .kit experience - # files in apps/ register "${app}/../source" as an extension search folder, - # so each extension's config/extension.toml must remain there or the Kit - # dependency solver fails with "isaaclab_assets ... (none found)". We drop - # the inner Python package (imported from the promoted top-level copy above) - # and the duplicated data/ to avoid a second copy on sys.path and bloat, - # but leave config/extension.toml in place for discovery. - rm -rf "$inner" "$ext_dir/data" - fi -done - -# Clean build artifacts that shouldn't be in the wheel -find "$BUILD_DIR/src" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true -find "$BUILD_DIR/src" -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true -find "$BUILD_DIR/src" -name "*.pyc" -delete 2>/dev/null || true - -# 2. Copy the custom __main__.py used by ``python -m isaaclab`` -cp "$SELF_DIR/res/__main__.py" "$BUILD_DIR/src/isaaclab/" - -# 3. Generate pyproject.toml with dependencies from the root pyproject.toml -python3 "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/../../pyproject.toml" "$BUILD_DIR/pyproject.toml" "$WHEEL_VERSION" +# Stage the same aggregate source tree used by the PEP 517 Git-source backend. +python3 "$SELF_DIR/stage.py" "$BUILD_DIR" "$WHEEL_VERSION" # 4. Build the wheel cd "$BUILD_DIR" diff --git a/tools/wheel_builder/build_backend.py b/tools/wheel_builder/build_backend.py new file mode 100644 index 000000000000..f8234cf45979 --- /dev/null +++ b/tools/wheel_builder/build_backend.py @@ -0,0 +1,68 @@ +# 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 + +"""PEP 517 backend for building the aggregate Isaac Lab package from a source checkout.""" + +from __future__ import annotations + +import os +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +from setuptools import build_meta +from stage import stage_package + +_BUILDER_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _BUILDER_DIR.parents[1] + + +def _wheel_version() -> str: + version = (_REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + build_number = os.getenv("WHEEL_BUILD_NUMBER") + commit = os.getenv("WHEEL_SHA") + if build_number and commit: + return f"{version}+build{build_number}.{commit[:7]}" + return version + + +@contextmanager +def _staged_project() -> Iterator[Path]: + with tempfile.TemporaryDirectory(prefix="isaaclab-wheel-") as temp_dir: + stage_dir = Path(temp_dir) + stage_package(_REPO_ROOT, stage_dir, _wheel_version()) + previous_directory = Path.cwd() + os.chdir(stage_dir) + try: + yield stage_dir + finally: + os.chdir(previous_directory) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build the aggregate Isaac Lab wheel.""" + with _staged_project(): + return build_meta.build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str: + """Build an aggregate Isaac Lab source distribution.""" + with _staged_project(): + return build_meta.build_sdist(sdist_directory, config_settings) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare metadata for the aggregate Isaac Lab wheel.""" + with _staged_project(): + return build_meta.prepare_metadata_for_build_wheel(metadata_directory, config_settings) diff --git a/tools/wheel_builder/pyproject.toml b/tools/wheel_builder/pyproject.toml new file mode 100644 index 000000000000..7861684f3157 --- /dev/null +++ b/tools/wheel_builder/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = ["setuptools>=70.0,<82.0.0", "wheel"] +build-backend = "build_backend" +backend-path = ["."] diff --git a/tools/wheel_builder/stage.py b/tools/wheel_builder/stage.py new file mode 100644 index 000000000000..63cad5c775e1 --- /dev/null +++ b/tools/wheel_builder/stage.py @@ -0,0 +1,93 @@ +# 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 + +"""Stage the aggregate Isaac Lab Python package.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +def stage_package(repo_root: Path, stage_dir: Path, version: str) -> None: + """Create the aggregate package source tree consumed by the wheel builder.""" + builder_dir = repo_root / "tools" / "wheel_builder" + package_dir = stage_dir / "src" / "isaaclab" + + shutil.rmtree(stage_dir, ignore_errors=True) + package_dir.mkdir(parents=True) + + shutil.copytree(repo_root / "apps", package_dir / "apps") + shutil.copytree(repo_root / "source", package_dir / "source") + shutil.copytree(repo_root / "tools" / "template", package_dir / "tools" / "template") + + for directory in (package_dir / "apps").rglob("*"): + if directory.is_dir(): + (directory / "__init__.py").touch() + + core_package = package_dir / "source" / "isaaclab" / "isaaclab" + shutil.copytree(core_package, package_dir, dirs_exist_ok=True) + shutil.rmtree(core_package) + + for extension_dir in sorted((package_dir / "source").glob("isaaclab_*")): + package_name = extension_dir.name + inner_package = extension_dir / package_name + if not (inner_package / "__init__.py").is_file(): + continue + + installed_package = stage_dir / "src" / package_name + shutil.copytree(inner_package, installed_package) + for resource_name in ("config", "data"): + resource_dir = extension_dir / resource_name + if resource_dir.is_dir(): + shutil.copytree(resource_dir, installed_package / resource_name) + + init_path = installed_package / "__init__.py" + init_contents = init_path.read_text(encoding="utf-8") + init_contents = init_contents.replace( + 'os.path.join(os.path.dirname(__file__), "../"', + 'os.path.join(os.path.dirname(__file__), ""', + ) + init_path.write_text(init_contents, encoding="utf-8") + + shutil.rmtree(inner_package) + shutil.rmtree(extension_dir / "data", ignore_errors=True) + + for cache_dir in sorted(stage_dir.rglob("__pycache__"), reverse=True): + shutil.rmtree(cache_dir, ignore_errors=True) + for egg_info_dir in sorted(stage_dir.rglob("*.egg-info"), reverse=True): + shutil.rmtree(egg_info_dir, ignore_errors=True) + for bytecode_file in stage_dir.rglob("*.pyc"): + bytecode_file.unlink() + + shutil.copy2(builder_dir / "res" / "__main__.py", package_dir / "__main__.py") + subprocess.run( + [ + sys.executable, + str(builder_dir / "gen_pyproject.py"), + str(repo_root / "pyproject.toml"), + str(stage_dir / "pyproject.toml"), + version, + ], + check=True, + ) + + +def main() -> None: + """Stage an aggregate package from the containing Isaac Lab checkout.""" + parser = argparse.ArgumentParser() + parser.add_argument("stage_dir", type=Path) + parser.add_argument("version") + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parents[2] + stage_package(repo_root, args.stage_dir.resolve(), args.version) + + +if __name__ == "__main__": + main() From 684f4dc74192936929a6f0e11ef521c0328b511c Mon Sep 17 00:00:00 2001 From: Mustafa Haiderbhai Date: Thu, 3 Sep 2026 13:40:45 -0700 Subject: [PATCH 4/5] Correct aggregate wheel resource assertion --- source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index 6fa6e8b81870..b931ceccaffc 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -108,7 +108,6 @@ def test_isaaclab_package_has_flat_layout(self): assert "isaaclab/app/__init__.py" in names assert "isaaclab/apps/isaaclab.python.kit" in names - assert "isaaclab/source/isaaclab_assets/config/extension.toml" in names nested_prefix = "isaaclab/source/isaaclab/isaaclab/" assert not any(name.startswith(nested_prefix) for name in names) From eef3144ce7bb5b9bc1e3d015cea64fda16501b1c Mon Sep 17 00:00:00 2001 From: Mustafa Haiderbhai Date: Thu, 3 Sep 2026 14:07:24 -0700 Subject: [PATCH 5/5] Preserve aggregate wheel compatibility APIs --- source/isaaclab/isaaclab/__init__.py | 22 ++++++++++++++++ .../isaaclab/isaaclab}/__main__.py | 4 ++- source/isaaclab/isaaclab/app/app_launcher.py | 2 +- .../isaaclab/benchmark/microbenchmark.py | 2 +- .../recorders/record_version_info.py | 2 +- source/isaaclab/isaaclab/cli/utils.py | 2 +- .../isaaclab/isaaclab/{_paths.py => paths.py} | 0 source/isaaclab/isaaclab/utils/assets.py | 2 +- .../test_installed_workflow_entrypoints.py | 26 +++++++++++++++++-- .../test/cli/test_wheel_builder_metadata.py | 9 ++++++- tools/wheel_builder/gen_pyproject.py | 2 +- tools/wheel_builder/stage.py | 1 - 12 files changed, 63 insertions(+), 11 deletions(-) rename {tools/wheel_builder/res => source/isaaclab/isaaclab}/__main__.py (98%) rename source/isaaclab/isaaclab/{_paths.py => paths.py} (100%) diff --git a/source/isaaclab/isaaclab/__init__.py b/source/isaaclab/isaaclab/__init__.py index dea1b4c9c649..c7dff524ab64 100644 --- a/source/isaaclab/isaaclab/__init__.py +++ b/source/isaaclab/isaaclab/__init__.py @@ -131,3 +131,25 @@ def _expose_mujoco_usd_schemas(): __version__ = importlib.metadata.version("isaaclab") except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" + + +# TODO(myurasov-nv): bootstrap_kernel() is ported from the internal GitLab wheel builder +# for backwards compatibility. It is not called currently, but may be needed if Isaac Sim +# requires explicit kernel bootstrapping before use. Remove once confirmed unnecessary. +def bootstrap_kernel(): + """Import Isaac Sim so it can initialize its kernel when available.""" + isaaclab_path = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) + if importlib.util.find_spec("isaacsim") is not None: + import isaacsim # noqa: F401 + + if importlib.util.find_spec("carb") is not None: + import carb + + carb.log_info(f"Isaac Lab path: {isaaclab_path}") + + +def main(): + """Run the ``isaaclab`` command through its compatibility dispatcher.""" + from isaaclab.__main__ import main as _main + + sys.exit(_main()) diff --git a/tools/wheel_builder/res/__main__.py b/source/isaaclab/isaaclab/__main__.py similarity index 98% rename from tools/wheel_builder/res/__main__.py rename to source/isaaclab/isaaclab/__main__.py index 8094789d26de..5ea66bbe64c3 100644 --- a/tools/wheel_builder/res/__main__.py +++ b/source/isaaclab/isaaclab/__main__.py @@ -11,6 +11,8 @@ import tomllib +from isaaclab.paths import ISAACLAB_ROOT + VSCODE_SETTINGS_TEMPLATE = """ { "editor.rulers": [120], @@ -115,7 +117,7 @@ def _get_paths(base_path: str, mock_python_modules: bool = False) -> list[str]: for folder in ["exts", "extscache", "extsDeprecated", "extsUser"]: extensions_paths.extend(_get_paths(os.path.join(isaacsim_path, folder), mock_python_modules=True)) # - isaaclab - isaaclab_path = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) + isaaclab_path = str(ISAACLAB_ROOT) for folder in ["source"]: extensions_paths.extend(_get_paths(os.path.join(isaaclab_path, folder), mock_python_modules=True)) diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 9f734fda6ebc..33cffcfccebe 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -33,10 +33,10 @@ SimulationApp = getattr(isaacsim, "SimulationApp", None) -from isaaclab._paths import ISAACLAB_ROOT from isaaclab.app.loading_screen import report_activity from isaaclab.app.logging_utils import apply_python_logging_level, resolve_python_logging_level from isaaclab.app.settings_manager import get_settings_manager, initialize_carb_settings +from isaaclab.paths import ISAACLAB_ROOT from isaaclab.utils._device import set_cuda_device from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING diff --git a/source/isaaclab/isaaclab/benchmark/microbenchmark.py b/source/isaaclab/isaaclab/benchmark/microbenchmark.py index 435cdcf09fc3..e5537d113536 100644 --- a/source/isaaclab/isaaclab/benchmark/microbenchmark.py +++ b/source/isaaclab/isaaclab/benchmark/microbenchmark.py @@ -13,8 +13,8 @@ from pathlib import Path from types import MappingProxyType -from isaaclab._paths import ISAACLAB_ROOT from isaaclab.cli.utils import run_python_command +from isaaclab.paths import ISAACLAB_ROOT @dataclass(frozen=True) diff --git a/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py b/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py index 06de005571a1..330363f4fd0c 100644 --- a/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py +++ b/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py @@ -8,9 +8,9 @@ import subprocess import sys -from isaaclab._paths import ISAACLAB_ROOT from isaaclab.benchmark.interfaces import MeasurementData, MeasurementDataRecorder from isaaclab.benchmark.measurements import DictMetadata, StringMetadata +from isaaclab.paths import ISAACLAB_ROOT # Path to the source checkout or installed wheel resources. _REPO_ROOT = str(ISAACLAB_ROOT) diff --git a/source/isaaclab/isaaclab/cli/utils.py b/source/isaaclab/isaaclab/cli/utils.py index 8390d13819c9..d585ce303d7b 100644 --- a/source/isaaclab/isaaclab/cli/utils.py +++ b/source/isaaclab/isaaclab/cli/utils.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import IO, Any -from isaaclab._paths import ISAACLAB_ROOT +from isaaclab.paths import ISAACLAB_ROOT # Default path to look for Isaac Sim is _isaac_sim symlink. DEFAULT_ISAAC_SIM_PATH = ISAACLAB_ROOT / "_isaac_sim" diff --git a/source/isaaclab/isaaclab/_paths.py b/source/isaaclab/isaaclab/paths.py similarity index 100% rename from source/isaaclab/isaaclab/_paths.py rename to source/isaaclab/isaaclab/paths.py diff --git a/source/isaaclab/isaaclab/utils/assets.py b/source/isaaclab/isaaclab/utils/assets.py index b60996cafd7a..d93c2f360c16 100644 --- a/source/isaaclab/isaaclab/utils/assets.py +++ b/source/isaaclab/isaaclab/utils/assets.py @@ -29,7 +29,7 @@ from filelock import FileLock -from isaaclab._paths import ISAACLAB_ROOT +from isaaclab.paths import ISAACLAB_ROOT logger = logging.getLogger(__name__) diff --git a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py index ea161651cb0a..9a8951730e8f 100644 --- a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py +++ b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py @@ -12,8 +12,10 @@ import pytest -import isaaclab._paths as paths +import isaaclab +import isaaclab.__main__ as package_main import isaaclab.cli as cli +import isaaclab.paths as paths pytestmark = pytest.mark.unit @@ -23,10 +25,30 @@ def test_resolves_partial_source_checkout_root(tmp_path): package_root = tmp_path / "source" / "isaaclab" / "isaaclab" package_root.mkdir(parents=True) - with mock.patch.object(paths, "__file__", str(package_root / "_paths.py")): + with mock.patch.object(paths, "__file__", str(package_root / "paths.py")): assert paths._resolve_isaaclab_root() == tmp_path +def test_top_level_compatibility_api_is_preserved(): + """The flattened package must retain the aggregate wheel's public shims.""" + assert callable(isaaclab.bootstrap_kernel) + with mock.patch.object(package_main, "main", return_value=0) as main, pytest.raises(SystemExit, match="0"): + isaaclab.main() + + main.assert_called_once_with() + + +def test_legacy_vscode_option_uses_compatibility_dispatcher(): + """The installed entry point must continue to recognize the legacy VS Code option.""" + with ( + mock.patch.object(sys, "argv", ["isaaclab", "--generate-vscode-settings"]), + mock.patch.object(package_main, "generate_vscode_settings") as generate, + ): + package_main.main() + + generate.assert_called_once_with() + + @pytest.mark.parametrize( ("command", "runner"), [ diff --git a/source/isaaclab/test/cli/test_wheel_builder_metadata.py b/source/isaaclab/test/cli/test_wheel_builder_metadata.py index c08ae714b1bc..d40624b8145b 100644 --- a/source/isaaclab/test/cli/test_wheel_builder_metadata.py +++ b/source/isaaclab/test/cli/test_wheel_builder_metadata.py @@ -81,7 +81,7 @@ def test_wheel_builder_drops_workspace_members(tmp_path): def test_wheel_console_delegates_to_the_full_isaaclab_cli(): """The wheel console command must expose the same workflows as a source installation.""" - module_path = _repo_root() / "tools" / "wheel_builder" / "res" / "__main__.py" + module_path = _repo_root() / "source" / "isaaclab" / "isaaclab" / "__main__.py" spec = util.spec_from_file_location("_isaaclab_wheel_main", module_path) assert spec is not None assert spec.loader is not None @@ -94,6 +94,13 @@ def test_wheel_console_delegates_to_the_full_isaaclab_cli(): cli.assert_called_once_with() +def test_wheel_console_uses_compatibility_dispatcher(tmp_path): + """The generated console script must preserve legacy installed-wheel options.""" + generated = _generate_wheel_pyproject(tmp_path) + + assert generated["project"]["scripts"]["isaaclab"] == "isaaclab.__main__:main" + + def test_wheel_builder_includes_isaacsim_extra(tmp_path): """The ``isaacsim`` extra must ship in the generated wheel metadata.""" generated = _generate_wheel_pyproject(tmp_path) diff --git a/tools/wheel_builder/gen_pyproject.py b/tools/wheel_builder/gen_pyproject.py index 53e48f8fab49..7e53956dad01 100644 --- a/tools/wheel_builder/gen_pyproject.py +++ b/tools/wheel_builder/gen_pyproject.py @@ -124,7 +124,7 @@ def _dedup(requirements: list[str]) -> list[str]: lines.append("]") lines.append("") lines.append("[project.scripts]") -lines.append('isaaclab = "isaaclab.cli:cli"') +lines.append('isaaclab = "isaaclab.__main__:main"') lines.append("") lines.append("[project.optional-dependencies]") for name, dep_list in opt_deps.items(): diff --git a/tools/wheel_builder/stage.py b/tools/wheel_builder/stage.py index 63cad5c775e1..c7c7a5f364c7 100644 --- a/tools/wheel_builder/stage.py +++ b/tools/wheel_builder/stage.py @@ -65,7 +65,6 @@ def stage_package(repo_root: Path, stage_dir: Path, version: str) -> None: for bytecode_file in stage_dir.rglob("*.pyc"): bytecode_file.unlink() - shutil.copy2(builder_dir / "res" / "__main__.py", package_dir / "__main__.py") subprocess.run( [ sys.executable,