Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/source/setup/installation/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<git-revision>", 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.

Expand Down
5 changes: 5 additions & 0 deletions source/isaaclab/changelog.d/flatten-wheel-package.rst
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions source/isaaclab/changelog.d/git-source-wheel.rst
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions source/isaaclab/isaaclab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

import tomllib

from isaaclab.paths import ISAACLAB_ROOT

VSCODE_SETTINGS_TEMPLATE = """
{
"editor.rulers": [120],
Expand Down Expand Up @@ -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))

Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/isaaclab/app/app_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
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

Expand Down Expand Up @@ -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():
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/isaaclab/benchmark/microbenchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from types import MappingProxyType

from isaaclab.cli.utils import run_python_command
from isaaclab.paths import ISAACLAB_ROOT


@dataclass(frozen=True)
Expand Down Expand Up @@ -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, ...]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@

from isaaclab.benchmark.interfaces import MeasurementData, MeasurementDataRecorder
from isaaclab.benchmark.measurements import DictMetadata, StringMetadata
from isaaclab.paths import ISAACLAB_ROOT

# 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):
Expand Down
3 changes: 1 addition & 2 deletions source/isaaclab/isaaclab/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions source/isaaclab/isaaclab/paths.py
Original file line number Diff line number Diff line change
@@ -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 / "source" / "isaaclab" / "isaaclab" == package_root:
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."""
6 changes: 3 additions & 3 deletions source/isaaclab/isaaclab/utils/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

from filelock import FileLock

from isaaclab.paths import ISAACLAB_ROOT

logger = logging.getLogger(__name__)

_UDIM_RE = re.compile(r"<UDIM>", re.IGNORECASE)
Expand All @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions source/isaaclab/test/cli/test_installed_workflow_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,43 @@

import pytest

import isaaclab
import isaaclab.__main__ as package_main
import isaaclab.cli as cli
import isaaclab.paths as paths

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


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"),
[
Expand Down
9 changes: 8 additions & 1 deletion source/isaaclab/test/cli/test_wheel_builder_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@

"""
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 <wheel>[sb3,skrl,rsl-rl]
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
Expand All @@ -27,6 +28,7 @@

import glob
import shutil
import zipfile

import pytest
from utils import UV_Mixin, run_cmd
Expand All @@ -49,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"))
Expand Down Expand Up @@ -92,6 +101,27 @@ 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
assert "isaaclab/apps/isaaclab.python.kit" 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."""
Expand Down
55 changes: 2 additions & 53 deletions tools/wheel_builder/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,60 +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 \;

# 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 res __init__.py and __main__.py
cp "$SELF_DIR/res/__init__.py" "$BUILD_DIR/src/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"
Expand Down
Loading
Loading