diff --git a/.github/actions/run-tests/run_tests.sh b/.github/actions/run-tests/run_tests.sh index 2c5ee3b066a9..6046a4e2fe12 100755 --- a/.github/actions/run-tests/run_tests.sh +++ b/.github/actions/run-tests/run_tests.sh @@ -344,6 +344,16 @@ run_tests() { ./isaaclab.sh -p -c \"import importlib.metadata,json,os,pathlib; from packaging.version import Version; manifest=json.loads(pathlib.Path(os.environ['TEST_WHEELHOUSE_MANIFEST']).read_text(encoding='utf-8')); expected=manifest.get('ovphysx_version'); actual=importlib.metadata.version('ovphysx'); print(f'Resolved ovphysx package version: {actual}'); print(f'Wheelhouse manifest ovphysx version: {expected}'); import ovphysx; runtime=getattr(ovphysx, '__version__', actual); print(f'Imported ovphysx runtime version: {runtime}'); raise SystemExit(0 if Version(actual) == Version(expected) and Version(runtime) == Version(expected) else f'ovphysx version mismatch: installed {actual}, import {runtime}, manifest {expected}')\" ;; esac + case \" \${TEST_WHEELHOUSE_PACKAGES} \" in + *\" ovrtx \"*) + ./isaaclab.sh -p -c \"import hashlib,importlib.metadata,json,os,pathlib; from packaging.version import Version; manifest=json.loads(pathlib.Path(os.environ['TEST_WHEELHOUSE_MANIFEST']).read_text(encoding='utf-8')); record=manifest.get('ovrtx') or manifest.get('packages', {}).get('ovrtx', {}); wheel=pathlib.Path(os.environ['TEST_WHEELHOUSE_PATH']) / record.get('file', ''); expected=record.get('version') or manifest.get('ovrtx_version'); actual=importlib.metadata.version('ovrtx'); digest=hashlib.file_digest(wheel.open('rb'), 'sha256').hexdigest(); import ovrtx; from ovrtx import _version; expected_ref=record.get('source_ref'); expected_sha=record.get('source_sha'); print(f'Resolved ovrtx package version: {actual}'); print(f'Wheelhouse manifest ovrtx version: {expected}'); print(f'Wheelhouse ovrtx SHA-256: {digest}'); print(f'Imported ovrtx provenance: {_version.gitbranch}@{_version.githash}'); raise SystemExit(0 if Version(actual) == Version(expected) and digest == record.get('sha256') and _version.gitbranch == expected_ref and _version.githash == expected_sha else f'ovrtx wheelhouse mismatch: installed {actual}, manifest {expected}, sha {digest}, provenance {_version.gitbranch}@{_version.githash}')\" + ;; + esac + case \" \${TEST_WHEELHOUSE_PACKAGES} \" in + *\" ovstage \"*) + ./isaaclab.sh -p -c \"import hashlib,importlib.metadata,json,os,pathlib; from packaging.version import Version; manifest=json.loads(pathlib.Path(os.environ['TEST_WHEELHOUSE_MANIFEST']).read_text(encoding='utf-8')); record=manifest.get('ovstage') or manifest.get('packages', {}).get('ovstage', {}); wheel=pathlib.Path(os.environ['TEST_WHEELHOUSE_PATH']) / record.get('file', ''); expected=record.get('version') or manifest.get('ovstage_version'); actual=importlib.metadata.version('ovstage'); digest=hashlib.file_digest(wheel.open('rb'), 'sha256').hexdigest(); import ovstage; runtime=getattr(ovstage, '__version__', actual); print(f'Resolved ovstage package version: {actual}'); print(f'Wheelhouse manifest ovstage version: {expected}'); print(f'Wheelhouse ovstage SHA-256: {digest}'); raise SystemExit(0 if Version(actual) == Version(expected) and Version(runtime) == Version(expected) and digest == record.get('sha256') else f'ovstage wheelhouse mismatch: installed {actual}, import {runtime}, manifest {expected}, sha {digest}')\" + ;; + esac fi if [ -n \"\${TEST_EXTRA_PIP_PACKAGES:-}\" ]; then echo \"Installing extra pip packages: \${TEST_EXTRA_PIP_PACKAGES}\" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8b5566d418f1..90094663a232 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -53,7 +53,7 @@ on: - develop - 'release/**' pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, labeled] branches: - main - develop @@ -77,6 +77,7 @@ env: jobs: changes: name: Detect Changes + if: github.event.action != 'labeled' || github.event.label.name == 'ci:run-ovstage' runs-on: ubuntu-latest permissions: contents: read @@ -855,7 +856,8 @@ jobs: container-name: "isaac-lab-rendering-correctness-kitless-legacy-test" omni-github-test-type: "rendering-correctness-kitless-legacy" - # ovstage is post-merge only; it never ran on pull requests. + # ovstage is post-merge by default. Maintainers can opt a PR into this expensive lane with + # the ci:run-ovstage label when validating renderer or OVStage integration changes. test-rendering-correctness-kitless-ovstage: name: "rendering-correctness-kitless (ovstage)" runs-on: [self-hosted, gpu] @@ -863,7 +865,8 @@ jobs: continue-on-error: ${{ github.event_name == 'workflow_dispatch' }} needs: [build, config] if: >- - github.event_name != 'pull_request' && + (github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'ci:run-ovstage')) && needs.build.result == 'success' env: USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_image != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} diff --git a/source/isaaclab_ov/changelog.d/esekkin-ovrtx-pairing.rst b/source/isaaclab_ov/changelog.d/esekkin-ovrtx-pairing.rst new file mode 100644 index 000000000000..d3199550b6de --- /dev/null +++ b/source/isaaclab_ov/changelog.d/esekkin-ovrtx-pairing.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added an OVRTX/OVStage version compatibility check before native renderer construction. OVRTX 0.4 + requires OVStage 0.1, while OVRTX 0.5 requires ``ovstage>=0.2.0.370625,<0.3``. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_compat.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_compat.py index d46d6c00d1f8..ecbc79c78515 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_compat.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_compat.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Version compatibility between the public OVRTX 0.4 API and OVRTX 0.5 and later. +"""Version compatibility between OVRTX, OVStage, and Isaac Lab. OVRTX 0.4 keys ``frame.render_vars`` by render-var source name (``"LdrColor"``), while 0.5 keys it by the authored RenderVar prim path (``"/Render/Vars/LdrColor"``). The @@ -11,6 +11,10 @@ at import and published as :data:`RENDER_VAR_FRAME_KEYS`; per-frame code indexes that mapping instead of re-checking the version. +OVRTX and OVStage also expose a native ABI boundary that package metadata does not constrain. +The supported pair is checked before renderer construction so an incompatible install fails with +an actionable Python exception instead of aborting in native OVStage population. + The public extras stay pinned to ``ovrtx==0.4.1.364340``; a missing or unparsable install keeps the 0.4 key form. """ @@ -30,6 +34,12 @@ # First OVRTX version that keys ``frame.render_vars`` by RenderVar prim path. _PRIM_PATH_RENDER_VARS_VERSION = Version("0.5") +_OVRTX_04_MIN_VERSION = Version("0.4") +_OVRTX_05_MIN_VERSION = Version("0.5") +_OVRTX_06_MIN_VERSION = Version("0.6") +_OVSTAGE_01_MIN_VERSION = Version("0.1.1.355824") +_OVSTAGE_02_MIN_VERSION = Version("0.2.0.370625") +_OVSTAGE_03_MIN_VERSION = Version("0.3") def detect_ovrtx_version() -> Version | None: @@ -54,6 +64,64 @@ def detect_ovrtx_version() -> Version | None: return None +def detect_ovstage_version() -> Version | None: + """Return the installed ``ovstage`` version. + + Returns: + The installed version, or ``None`` when ``ovstage`` is absent or its version string + cannot be parsed. + """ + try: + raw = importlib.metadata.version("ovstage") + except importlib.metadata.PackageNotFoundError: + return None + try: + return Version(raw) + except InvalidVersion: + logger.warning("Could not parse ovstage version %r.", raw) + return None + + +def validate_ovrtx_ovstage_compatibility( + ovrtx_version: Version | None, + ovstage_version: Version | None, +) -> None: + """Validate the native OVRTX and OVStage package pairing. + + Args: + ovrtx_version: Installed OVRTX version, or ``None`` when it cannot be resolved. + ovstage_version: Installed OVStage version, or ``None`` when it cannot be resolved. + + Raises: + RuntimeError: If a known OVRTX release line is paired with an unsupported OVStage version. + """ + required_ovstage: str | None = None + is_compatible = True + if ovrtx_version is not None and _OVRTX_04_MIN_VERSION <= ovrtx_version < _OVRTX_05_MIN_VERSION: + required_ovstage = ">=0.1.1.355824,<0.2" + is_compatible = ( + ovstage_version is not None and _OVSTAGE_01_MIN_VERSION <= ovstage_version < _OVSTAGE_02_MIN_VERSION + ) + elif ovrtx_version is not None and _OVRTX_05_MIN_VERSION <= ovrtx_version < _OVRTX_06_MIN_VERSION: + required_ovstage = ">=0.2.0.370625,<0.3" + is_compatible = ( + ovstage_version is not None and _OVSTAGE_02_MIN_VERSION <= ovstage_version < _OVSTAGE_03_MIN_VERSION + ) + + if required_ovstage is not None and not is_compatible: + installed_ovstage = str(ovstage_version) if ovstage_version is not None else "missing or unparsable" + raise RuntimeError( + "Unsupported OVRTX/OVStage package combination: " + f"ovrtx {ovrtx_version} requires ovstage{required_ovstage}, but found {installed_ovstage}. " + "Install a compatible pair before creating OVRTXRenderer." + ) + + +def validate_installed_ovrtx_ovstage() -> None: + """Validate the installed OVRTX and OVStage distributions.""" + validate_ovrtx_ovstage_compatibility(OVRTX_VERSION, OVSTAGE_VERSION) + + def uses_prim_path_render_vars(version: Version | None) -> bool: """Return whether ``version`` keys ``frame.render_vars`` by RenderVar prim path. @@ -85,5 +153,8 @@ def build_render_var_frame_keys(version: Version | None) -> Mapping[str, str]: OVRTX_VERSION: Version | None = detect_ovrtx_version() """Installed OVRTX version, or ``None`` when it is unavailable or unparsable.""" +OVSTAGE_VERSION: Version | None = detect_ovstage_version() +"""Installed OVStage version, or ``None`` when it is unavailable or unparsable.""" + RENDER_VAR_FRAME_KEYS: Mapping[str, str] = build_render_var_frame_keys(OVRTX_VERSION) """Maps render-var source name to its ``frame.render_vars`` key for the installed OVRTX.""" diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index cf2a8344367a..f46437edba96 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -85,7 +85,7 @@ decode_stable_id_map, decode_stable_id_semantic_id_map, ) -from .ovrtx_compat import RENDER_VAR_FRAME_KEYS +from .ovrtx_compat import RENDER_VAR_FRAME_KEYS, validate_installed_ovrtx_ovstage from .ovrtx_mapping import cuda_device_id from .ovrtx_renderer_cfg import OVRTXRendererCfg from .ovrtx_renderer_kernels import ( @@ -322,6 +322,7 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: } def __init__(self, cfg: OVRTXRendererCfg): + validate_installed_ovrtx_ovstage() self.cfg = cfg self._device = "cuda:0" # default; overridden by create_render_data(spec) self._render_product_paths = [] diff --git a/source/isaaclab_ov/test/test_ovrtx_compat.py b/source/isaaclab_ov/test/test_ovrtx_compat.py index 8a9bd1139558..c040208d1124 100644 --- a/source/isaaclab_ov/test/test_ovrtx_compat.py +++ b/source/isaaclab_ov/test/test_ovrtx_compat.py @@ -29,15 +29,19 @@ RENDER_VAR_FRAME_KEYS, build_render_var_frame_keys, detect_ovrtx_version, + detect_ovstage_version, uses_prim_path_render_vars, + validate_ovrtx_ovstage_compatibility, ) from isaaclab_ov.renderers.ovrtx_usd import render_var_prim_paths_by_source # noqa: E402 else: RENDER_VAR_FRAME_KEYS = None build_render_var_frame_keys = None + detect_ovstage_version = None detect_ovrtx_version = None render_var_prim_paths_by_source = None uses_prim_path_render_vars = None + validate_ovrtx_ovstage_compatibility = None def test_detect_ovrtx_version_reads_distribution_metadata(monkeypatch: pytest.MonkeyPatch): @@ -58,6 +62,61 @@ def test_detect_ovrtx_version_returns_none_for_unparseable_version(monkeypatch: assert detect_ovrtx_version() is None +def test_detect_ovstage_version_reads_distribution_metadata(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.2.0.370625") + assert detect_ovstage_version() == Version("0.2.0.370625") + + +def test_detect_ovstage_version_returns_none_when_uninstalled(monkeypatch: pytest.MonkeyPatch): + def _missing(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", _missing) + assert detect_ovstage_version() is None + + +def test_detect_ovstage_version_returns_none_for_unparseable_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "internal-build") + assert detect_ovstage_version() is None + + +@pytest.mark.parametrize( + ("ovrtx_version", "ovstage_version"), + [ + (None, None), + (Version("0.4.1.364340"), Version("0.1.1.355824")), + (Version("0.4.9"), Version("0.1.9")), + (Version("0.5.0.0"), Version("0.2.0.370625")), + (Version("0.5.9"), Version("0.2.9")), + (Version("0.6"), None), + ], +) +def test_validate_ovrtx_ovstage_compatibility_accepts_supported_pairs( + ovrtx_version: Version | None, + ovstage_version: Version | None, +): + validate_ovrtx_ovstage_compatibility(ovrtx_version, ovstage_version) + + +@pytest.mark.parametrize( + ("ovrtx_version", "ovstage_version", "required"), + [ + (Version("0.4.1.364340"), None, "ovstage>=0.1.1.355824,<0.2"), + (Version("0.4.1.364340"), Version("0.2.0.370625"), "ovstage>=0.1.1.355824,<0.2"), + (Version("0.5.0.0"), None, "ovstage>=0.2.0.370625,<0.3"), + (Version("0.5.0.0"), Version("0.1.1.355824"), "ovstage>=0.2.0.370625,<0.3"), + (Version("0.5.0.0"), Version("0.3"), "ovstage>=0.2.0.370625,<0.3"), + ], +) +def test_validate_ovrtx_ovstage_compatibility_rejects_unsupported_pairs( + ovrtx_version: Version, + ovstage_version: Version | None, + required: str, +): + with pytest.raises(RuntimeError, match=required): + validate_ovrtx_ovstage_compatibility(ovrtx_version, ovstage_version) + + @pytest.mark.parametrize( ("version", "expected"), [ diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 4469b5ccc93d..fceb0635b5fd 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -99,6 +99,7 @@ def __init__(self, **kwargs): monkeypatch.setattr(ovrtx_renderer_module, "RendererConfig", RecordingRendererConfig) monkeypatch.setattr(ovrtx_renderer_module, "Renderer", lambda config: object()) # noqa: ARG005 monkeypatch.setattr(ovrtx_renderer_module, "ovrtx_use_ovstage_enabled", lambda: False) + monkeypatch.setattr(ovrtx_renderer_module, "validate_installed_ovrtx_ovstage", lambda: None) renderer = OVRTXRenderer(OVRTXRendererCfg())