Skip to content

Commit 5e6429e

Browse files
authored
[NEWTON]Enables omni-client be installed with packman (#4141)
# Description This is an alternative to PR #4134 which provide solution as client.py here is a brief discussion around pros and cons between these choices Created a new folder tools/installation as the first step to use modular python file for installation decoupling # USD Client Options ## Option 1: `client.py` (pure Python) #4134 **Pros** - Fully transparent: if buggy, user can fix or submit issue - Relatively simple - No system config; only depends on Python stdlib **Cons** - May not handle some user URLs robustly (even with `urllib`) - Need to re-implement: - `stats(url) -> Result` - Returns: `OK`, `ERROR_NOT_FOUND`, `ERROR_PERMISSION_DENIED`, `ERROR_NETWORK`, `ERROR_UNKNOWN` - `read_file(url) -> data in memory` - `copy(url, local_path)` → downloads file --- ## Option 2: `omni.client` via Packman **Pros** - No need to re-implement `stats`, `read_file`, `copy` - Should be robust, and has many nice future feature. **Cons** - Recursive reference download still needs to be implemented - Not transparent / not easily editable if buggy - Must handle system differences - Needs install script (~100 lines) that: - Downloads `omni_client.7z` to temp folder and extract content. - Copies `release/bindings-python/omni/...` into `_omni_client/` and writes `omni_client.pth` - Copies `libomni*.so*` into `_omni_client/lib/` - Symlinks (or copies) those libs into `_omni_client/omni/client/` ## Type of change <!-- As you go through the list, delete the ones that are not applicable. --> - New feature (non-breaking change which adds functionality) ## Screenshots Please attach before and after screenshots of the change if applicable. <!-- Example: | Before | After | | ------ | ----- | | _gif/png before_ | _gif/png after_ | To upload images to a PR -- simply drag and drop an image while in edit mode and it should upload the image directly. You can then paste that source into the above before/after sections. --> ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there <!-- As you go through the checklist above, you can mark something as done by putting an x character in it For example, - [x] I have done this task - [ ] I have not done this task -->
1 parent 74d3500 commit 5e6429e

File tree

4 files changed

+283
-27
lines changed

4 files changed

+283
-27
lines changed

isaaclab.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@ while [[ $# -gt 0 ]]; do
348348
# install the python packages in IsaacLab/source directory
349349
echo "[INFO] Installing extensions inside the Isaac Lab repository..."
350350
python_exe=$(extract_python_exe)
351+
# install omni.client via packman helper
352+
${python_exe} "${ISAACLAB_PATH}/tools/installation/install_omni_client_packman.py"
351353
# check if pytorch is installed and its version
352354
# install pytorch with cuda 12.8 for blackwell support
353355
if ${python_exe} -m pip list 2>/dev/null | grep -q "torch"; then

source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
select_usd_variants,
2929
)
3030
from isaaclab.sim.utils.stage import get_current_stage
31+
from isaaclab.utils.assets import check_file_path, retrieve_file_path
3132

3233
if TYPE_CHECKING:
3334
from . import from_files_cfg
@@ -258,20 +259,16 @@ def _spawn_from_usd_file(
258259
Raises:
259260
FileNotFoundError: If the USD file does not exist at the given path.
260261
"""
261-
# get stage handle
262-
stage = get_current_stage()
263-
264-
# check file path exists
265-
if not stage.ResolveIdentifierToEditTarget(usd_path):
266-
if "4.5" in usd_path:
267-
usd_5_0_path = (
268-
usd_path.replace("http", "https").replace("-production.", "-staging.").replace("/4.5", "/5.0")
269-
)
270-
if not stage.ResolveIdentifierToEditTarget(usd_5_0_path):
271-
raise FileNotFoundError(f"USD file not found at path at either: '{usd_path}' or '{usd_5_0_path}'.")
272-
usd_path = usd_5_0_path
273-
else:
274-
raise FileNotFoundError(f"USD file not found at path at: '{usd_path}'.")
262+
# check file path exists (supports local paths, S3, HTTP/HTTPS URLs)
263+
# check_file_path returns: 0 (not found), 1 (local), 2 (remote)
264+
file_status = check_file_path(usd_path)
265+
if file_status == 0:
266+
raise FileNotFoundError(f"USD file not found at path: '{usd_path}'.")
267+
268+
# Download remote files (S3, HTTP, HTTPS) to local cache
269+
# This also downloads all USD dependencies to maintain references
270+
if file_status == 2:
271+
usd_path = retrieve_file_path(usd_path)
275272
# spawn asset if it doesn't exist.
276273
if not prim_utils.is_prim_path_valid(prim_path):
277274
# add prim as reference to stage

source/isaaclab/isaaclab/utils/assets.py

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,21 @@
1414
"""
1515

1616
import io
17+
import logging
1718
import os
19+
import posixpath
1820
import tempfile
21+
from pathlib import Path
1922
from typing import Literal
23+
from urllib.parse import urlparse
2024

21-
import carb
2225
import omni.client
2326

24-
NUCLEUS_ASSET_ROOT_DIR = carb.settings.get_settings().get("/persistent/isaac/asset_root/cloud")
25-
"""Path to the root directory on the Nucleus Server."""
27+
logger = logging.getLogger(__name__)
28+
from pxr import Sdf
29+
30+
NUCLEUS_ASSET_ROOT_DIR = "https://omniverse-content-production.s3-us-west-2.amazonaws.com/Assets/Isaac/5.0"
31+
"""Path to the root directory on the cloud storage."""
2632

2733
NVIDIA_NUCLEUS_DIR = f"{NUCLEUS_ASSET_ROOT_DIR}/NVIDIA"
2834
"""Path to the root directory on the NVIDIA Nucleus Server."""
@@ -33,6 +39,8 @@
3339
ISAACLAB_NUCLEUS_DIR = f"{ISAAC_NUCLEUS_DIR}/IsaacLab"
3440
"""Path to the ``Isaac/IsaacLab`` directory on the NVIDIA Nucleus Server."""
3541

42+
USD_EXTENSIONS = {".usd", ".usda", ".usdz"}
43+
3644

3745
def check_file_path(path: str) -> Literal[0, 1, 2]:
3846
"""Checks if a file exists on the Nucleus Server or locally.
@@ -91,16 +99,38 @@ def retrieve_file_path(path: str, download_dir: str | None = None, force_downloa
9199
# create download directory if it does not exist
92100
if not os.path.exists(download_dir):
93101
os.makedirs(download_dir)
94-
# download file in temp directory using os
95-
file_name = os.path.basename(omni.client.break_url(path.replace(os.sep, "/")).path)
96-
target_path = os.path.join(download_dir, file_name)
97-
# check if file already exists locally
98-
if not os.path.isfile(target_path) or force_download:
99-
# copy file to local machine
100-
result = omni.client.copy(path.replace(os.sep, "/"), target_path, omni.client.CopyBehavior.OVERWRITE)
101-
if result != omni.client.Result.OK and force_download:
102-
raise RuntimeError(f"Unable to copy file: '{path}'. Is the Nucleus Server running?")
103-
return os.path.abspath(target_path)
102+
# recursive download: mirror remote tree under download_dir
103+
remote_url = path.replace(os.sep, "/")
104+
to_visit = [remote_url]
105+
visited = set()
106+
local_root = None
107+
108+
while to_visit:
109+
cur_url = to_visit.pop()
110+
if cur_url in visited:
111+
continue
112+
visited.add(cur_url)
113+
114+
cur_rel = urlparse(cur_url).path.lstrip("/")
115+
target_path = os.path.join(download_dir, cur_rel)
116+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
117+
118+
if not os.path.isfile(target_path) or force_download:
119+
result = omni.client.copy(cur_url, target_path, omni.client.CopyBehavior.OVERWRITE)
120+
if result != omni.client.Result.OK and force_download:
121+
raise RuntimeError(f"Unable to copy file: '{cur_url}'. Is the Nucleus Server running?")
122+
123+
if local_root is None:
124+
local_root = target_path
125+
126+
# recurse into USD dependencies and referenced assets
127+
if Path(target_path).suffix.lower() in USD_EXTENSIONS:
128+
for ref in _find_usd_references(target_path):
129+
ref_url = _resolve_reference_url(cur_url, ref)
130+
if ref_url and ref_url not in visited:
131+
to_visit.append(ref_url)
132+
133+
return os.path.abspath(local_root)
104134
else:
105135
raise FileNotFoundError(f"Unable to find the file: {path}")
106136

@@ -127,3 +157,102 @@ def read_file(path: str) -> io.BytesIO:
127157
return io.BytesIO(memoryview(file_content).tobytes())
128158
else:
129159
raise FileNotFoundError(f"Unable to find the file: {path}")
160+
161+
162+
def _is_downloadable_asset(path: str) -> bool:
163+
"""Return True for USD or other asset types we mirror locally (textures, etc.)."""
164+
clean = path.split("?", 1)[0].split("#", 1)[0]
165+
suffix = Path(clean).suffix.lower()
166+
167+
if suffix == ".mdl":
168+
# MDL modules (OmniPBR.mdl, OmniSurface.mdl, ...) come from MDL search paths
169+
return False
170+
if not suffix:
171+
return False
172+
if suffix not in {".usd", ".usda", ".usdz", ".png", ".jpg", ".jpeg", ".exr", ".hdr", ".tif", ".tiff"}:
173+
return False
174+
return True
175+
176+
177+
def _find_usd_references(local_usd_path: str) -> set[str]:
178+
"""Use Sdf API to collect referenced assets from a USD layer."""
179+
try:
180+
layer = Sdf.Layer.FindOrOpen(local_usd_path)
181+
except Exception:
182+
logger.warning("Failed to open USD layer: %s", local_usd_path, exc_info=True)
183+
return set()
184+
185+
if layer is None:
186+
return set()
187+
188+
refs: set[str] = set()
189+
190+
# Sublayers
191+
for sub_path in getattr(layer, "subLayerPaths", []) or []:
192+
if sub_path and _is_downloadable_asset(sub_path):
193+
refs.add(str(sub_path))
194+
195+
def _walk_prim(prim_spec: Sdf.PrimSpec) -> None:
196+
# References
197+
ref_list = prim_spec.referenceList
198+
for field in ("addedItems", "prependedItems", "appendedItems", "explicitItems"):
199+
items = getattr(ref_list, field, None)
200+
if not items:
201+
continue
202+
for ref in items:
203+
asset_path = getattr(ref, "assetPath", None)
204+
if asset_path and _is_downloadable_asset(asset_path):
205+
refs.add(str(asset_path))
206+
207+
# Payloads
208+
payload_list = prim_spec.payloadList
209+
for field in ("addedItems", "prependedItems", "appendedItems", "explicitItems"):
210+
items = getattr(payload_list, field, None)
211+
if not items:
212+
continue
213+
for payload in items:
214+
asset_path = getattr(payload, "assetPath", None)
215+
if asset_path and _is_downloadable_asset(asset_path):
216+
refs.add(str(asset_path))
217+
218+
# AssetPath-valued attributes (this is where OmniPBR.mdl, textures, etc. show up)
219+
for attr_spec in prim_spec.attributes.values():
220+
default = attr_spec.default
221+
if isinstance(default, Sdf.AssetPath):
222+
if default.path and _is_downloadable_asset(default.path):
223+
refs.add(default.path)
224+
elif isinstance(default, Sdf.AssetPathArray):
225+
for ap in default:
226+
if ap.path and _is_downloadable_asset(ap.path):
227+
refs.add(ap.path)
228+
229+
for child in prim_spec.nameChildren.values():
230+
_walk_prim(child)
231+
232+
for root_prim in layer.rootPrims.values():
233+
_walk_prim(root_prim)
234+
235+
return refs
236+
237+
238+
def _resolve_reference_url(base_url: str, ref: str) -> str:
239+
"""Resolve a USD asset reference against a base URL (http/local)."""
240+
ref = ref.strip()
241+
if not ref:
242+
return ref
243+
244+
parsed_ref = urlparse(ref)
245+
if parsed_ref.scheme:
246+
return ref
247+
248+
base = urlparse(base_url)
249+
if base.scheme == "":
250+
base_dir = os.path.dirname(base_url)
251+
return os.path.normpath(os.path.join(base_dir, ref))
252+
253+
base_dir = posixpath.dirname(base.path)
254+
if ref.startswith("/"):
255+
new_path = posixpath.normpath(ref)
256+
else:
257+
new_path = posixpath.normpath(posixpath.join(base_dir, ref))
258+
return f"{base.scheme}://{base.netloc}{new_path}"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
3+
# All rights reserved.
4+
#
5+
# SPDX-License-Identifier: BSD-3-Clause
6+
7+
"""
8+
Install omni.client from a prebuilt 7z payload into the current Python environment.
9+
10+
- Downloads https://d4i3qtqj3r0z5.cloudfront.net/omni_client_library.linux-x86_64@<version>.7z
11+
- Extracts to a cache dir (default: $TMPDIR/omni_client_cache; override with OMNI_CLIENT_CACHE)
12+
- Copies the Python package (release/bindings-python) and native libs (release/*.so) into site-packages/_omni_client
13+
- Drops a .pth for import visibility
14+
- Creates a minimal dist-info so `pip uninstall omni-client-offline` works
15+
16+
# TODO: Once pip has been shipped, remove this script and use pip install omniverseclient==<version> instead.
17+
"""
18+
19+
import logging
20+
import os
21+
import pathlib
22+
import shutil
23+
import site
24+
import subprocess
25+
import sys
26+
import tempfile
27+
import urllib.request
28+
29+
# Ensure py7zr is available
30+
try:
31+
import py7zr # type: ignore # noqa: F401
32+
except ImportError:
33+
subprocess.check_call([sys.executable, "-m", "pip", "install", "py7zr"])
34+
import py7zr # type: ignore
35+
36+
37+
logger = logging.getLogger(__name__)
38+
39+
# Configuration
40+
pkg_ver = os.environ.get("OMNI_CLIENT_VERSION", "2.68.1")
41+
cache_root = pathlib.Path(os.environ.get("OMNI_CLIENT_CACHE", tempfile.gettempdir())) / "omni_client_cache"
42+
payload_url = f"https://d4i3qtqj3r0z5.cloudfront.net/omni_client_library.linux-x86_64%40{pkg_ver}.7z"
43+
44+
# Paths
45+
cache_root.mkdir(parents=True, exist_ok=True)
46+
payload = cache_root / f"omni_client.{pkg_ver}.7z"
47+
extract_root = cache_root / f"omni_client.{pkg_ver}.extracted"
48+
49+
# Download payload if missing
50+
if not payload.exists():
51+
logger.info(f" Downloading omni.client payload from {payload_url} ...")
52+
urllib.request.urlretrieve(payload_url, payload)
53+
54+
# Extract payload only if not already present
55+
extract_root.mkdir(parents=True, exist_ok=True)
56+
already_extracted = (extract_root / "release" / "bindings-python" / "omni" / "client").exists()
57+
if not already_extracted:
58+
logger.info(f" Extracting omni.client payload into {extract_root} ...")
59+
with py7zr.SevenZipFile(payload, mode="r") as z:
60+
z.extractall(path=extract_root)
61+
else:
62+
logger.info(f" Reusing existing extraction at {extract_root}")
63+
64+
# Locate python package and native libs
65+
src_py = extract_root / "release" / "bindings-python"
66+
if not (src_py / "omni" / "client").exists():
67+
raise RuntimeError(f"Could not locate omni.client python package at {src_py}")
68+
69+
src_lib = extract_root / "release"
70+
if not any(src_lib.glob("libomni*.so*")):
71+
raise RuntimeError(f"Could not locate native libs under {src_lib}")
72+
73+
# Install into site-packages
74+
if hasattr(site, "getsitepackages"):
75+
candidates = [pathlib.Path(p) for p in site.getsitepackages() if p.startswith(sys.prefix)]
76+
site_pkgs = candidates[0] if candidates else pathlib.Path(site.getusersitepackages())
77+
else:
78+
site_pkgs = pathlib.Path(site.getusersitepackages())
79+
dest = site_pkgs / "_omni_client"
80+
dest.mkdir(parents=True, exist_ok=True)
81+
shutil.copytree(src_py, dest, dirs_exist_ok=True)
82+
shutil.copytree(src_lib, dest / "lib", dirs_exist_ok=True)
83+
84+
# Ensure the extension can find its libs without env vars
85+
client_dir = dest / "omni" / "client"
86+
client_dir.mkdir(parents=True, exist_ok=True)
87+
for libfile in (dest / "lib").glob("libomni*.so*"):
88+
target = client_dir / libfile.name
89+
if not target.exists():
90+
try:
91+
target.symlink_to(libfile)
92+
except Exception:
93+
shutil.copy2(libfile, target)
94+
95+
# Add .pth for import visibility
96+
with open(site_pkgs / "omni_client.pth", "w", encoding="utf-8") as f:
97+
f.write(str(dest) + "\n")
98+
f.write(str(dest / "lib") + "\n")
99+
100+
# Minimal dist-info so pip can uninstall (pip uninstall omni-client)
101+
dist_name = "omni-client"
102+
dist_info = site_pkgs / f"{dist_name.replace('-', '_')}-{pkg_ver}.dist-info"
103+
dist_info.mkdir(parents=True, exist_ok=True)
104+
(dist_info / "INSTALLER").write_text("manual\n", encoding="utf-8")
105+
metadata = "\n".join([
106+
f"Name: {dist_name}",
107+
f"Version: {pkg_ver}",
108+
"Summary: Offline omni.client bundle",
109+
"",
110+
])
111+
(dist_info / "METADATA").write_text(metadata, encoding="utf-8")
112+
113+
records = []
114+
for path in [
115+
site_pkgs / "omni_client.pth",
116+
dist_info / "INSTALLER",
117+
dist_info / "METADATA",
118+
]:
119+
records.append(f"{path.relative_to(site_pkgs)},,")
120+
for path in dest.rglob("*"):
121+
records.append(f"{path.relative_to(site_pkgs)},,")
122+
for path in dist_info.rglob("*"):
123+
if path.name != "RECORD":
124+
records.append(f"{path.relative_to(site_pkgs)},,")
125+
(dist_info / "RECORD").write_text("\n".join(records), encoding="utf-8")
126+
127+
logger.info(f"Installed omni.client to {dest} (dist: {dist_info.name})")
128+
logger.info("Uninstall with: pip uninstall omni-client")

0 commit comments

Comments
 (0)