diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index cc8608f03c22..d7dc7828e8d1 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -51,10 +51,16 @@ jobs: - name: Verify changelog fragments run: python3 tools/changelog/cli.py check ${{ steps.base.outputs.ref }} + # The unit tests run the real uv and towncrier (via uvx), as the nightly compile does. + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + # Keep equal to the uv in docker/Dockerfile.*. + version: "0.12.9" + - name: Run changelog tool unit tests # Gates tools/changelog/cli.py itself: regression tests for the - # filename rules, fragment parsing, CHANGELOG_HEADER_RE invariant, - # and cmd_check / cmd_compile orchestration. Without this, a cli.py + # fragment rules, the lock pins, the legacy split, and cmd_check / + # cmd_compile orchestration. Without this, a cli.py # refactor could silently break the gate above. # # Skipped when the PR doesn't touch tools/changelog/ — fragment-only diff --git a/.github/workflows/docs-windows.yaml b/.github/workflows/docs-windows.yaml new file mode 100644 index 000000000000..85b21f5c00aa --- /dev/null +++ b/.github/workflows/docs-windows.yaml @@ -0,0 +1,41 @@ +# 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 + +name: Windows Docs + +on: + pull_request: + paths: + - '.github/workflows/docs-windows.yaml' + - 'docs/**' + - 'pyproject.toml' + - 'uv.lock' + - 'source/isaaclab/isaaclab/cli/**' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build-docs: + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + python-version: '3.12' + enable-cache: true + - name: Build current documentation + run: uv run --extra dev isaaclab --docs + - name: Verify output + shell: pwsh + run: | + if (-not (Test-Path docs/_build/current/index.html)) { + throw 'Documentation index was not generated' + } diff --git a/.github/workflows/nightly-changelog.yml b/.github/workflows/nightly-changelog.yml index 2d4ff55003d4..5e0ca1f0d9cf 100644 --- a/.github/workflows/nightly-changelog.yml +++ b/.github/workflows/nightly-changelog.yml @@ -4,9 +4,10 @@ # SPDX-License-Identifier: BSD-3-Clause # Nightly auto-compile: rolls accumulated fragments under -# ``source//changelog.d/`` into per-package ``CHANGELOG.rst`` entries, -# bumps each ``extension.toml``, deletes consumed fragments, and pushes the -# result back to each branch in the configured list. Keeps every tracked +# ``source//changelog.d/`` into per-package ``CHANGELOG.rst`` entries +# (towncrier), bumps each package's ``pyproject.toml`` version, re-locks their +# ``uv.lock`` pins, deletes consumed fragments, and pushes the result back to +# each branch in the configured list. Keeps every tracked # branch's changelog current without requiring a maintainer to run # ``compile`` by hand. # @@ -14,9 +15,9 @@ # cron to register. See ``.github/workflows/README.md``. # # Scheduled runs combine ``env.CRON_BRANCHES`` with every remote -# ``release/*`` branch that carries ``tools/changelog/cli.py``. This picks up -# new compiler-enabled release branches automatically while leaving historical -# branches that predate the fragment system untouched. +# ``release/*`` branch that carries ``tools/changelog/towncrier.toml``. This picks +# up release branches as they take the towncrier tooling (by backport) and +# leaves older branches untouched. # # The push uses a short-lived GitHub App installation token minted from # ``CHANGELOG_APP_ID`` + ``CHANGELOG_APP_PRIVATE_KEY`` (repo secrets). The @@ -31,7 +32,7 @@ name: Nightly Changelog Compilation # Static branches the nightly cron compiles. The resolver also discovers every -# remote ``release/*`` branch that carries ``tools/changelog/cli.py``. Every +# remote ``release/*`` branch that carries ``tools/changelog/towncrier.toml``. Every # resolved branch must allow the isaaclab-bot App to bypass its ruleset. env: CRON_BRANCHES: develop @@ -46,9 +47,9 @@ on: # Manual trigger is always a single branch. Free-text on purpose # — scales to any branch (e.g. a new ``release/*`` cut from # develop) without needing to update the workflow. A branch - # that lacks ``tools/changelog/cli.py`` fails the verify step + # that lacks ``tools/changelog/towncrier.toml`` fails the verify step # below with a clear error, which is the desired failure mode. - description: 'Branch to compile (e.g. develop, release/3.0.0). Must carry tools/changelog/cli.py.' + description: 'Branch to compile (e.g. develop, release/3.0.0). Must carry tools/changelog/towncrier.toml.' required: true type: string dry_run: @@ -94,10 +95,10 @@ jobs: --header "Authorization: Bearer $API_TOKEN" \ --header 'Accept: application/vnd.github+json' \ --header 'X-GitHub-Api-Version: 2022-11-28' \ - "https://api.github.com/repos/$REPOSITORY/contents/tools/changelog/cli.py?ref=$encoded_branch") + "https://api.github.com/repos/$REPOSITORY/contents/tools/changelog/towncrier.toml?ref=$encoded_branch") case "$http_status" in 200) branches=$(printf '%s\n%s\n' "$branches" "$branch") ;; - 404) echo "::notice::Skipping $branch because it predates the changelog compiler." ;; + 404) echo "::notice::Skipping $branch because it predates the towncrier changelog tooling." ;; *) echo "::error::GitHub Contents API returned HTTP $http_status for $branch." exit 1 @@ -165,8 +166,7 @@ jobs: # ``isaaclab-bot`` — the bypass identity — and downstream CI # workflows DO trigger on the resulting commit. token: ${{ steps.app-token.outputs.token }} - # Full history so the compiler can resolve each fragment's merge - # time via ``git log --diff-filter=A --first-parent``. + # Full history for the rebase before push. fetch-depth: 0 - name: Verify changelog tooling exists on target branch @@ -175,17 +175,19 @@ jobs: run: | # Loud-fail this matrix entry (not the whole run — ``fail-fast: # false`` keeps siblings going) when the target branch lacks - # ``tools/changelog/cli.py``. A red tile is the desired signal: + # ``tools/changelog/towncrier.toml``. A red tile is the desired signal: # a branch in the nightly set without the compile tooling is a # configuration error, not a "no-op" condition. - if [ ! -f tools/changelog/cli.py ]; then - echo "::error::Branch '$TARGET_BRANCH' is missing tools/changelog/cli.py — drop it from env.CRON_BRANCHES (or the dispatch input) or restore the tooling on that branch." + if [ ! -f tools/changelog/towncrier.toml ]; then + echo "::error::Branch '$TARGET_BRANCH' is missing tools/changelog/towncrier.toml — drop it from env.CRON_BRANCHES (or the dispatch input) or backport the tooling to that branch." exit 1 fi - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 with: - python-version: "3.12" + # Keep equal to the uv in docker/Dockerfile.*: another uv release can + # re-serialize the whole lockfile, which ``compile`` then refuses. + version: "0.12.9" - name: Compile fragments # Capture the compile exit code so the next step can commit/push @@ -195,19 +197,16 @@ jobs: # which is exactly the wedge mode that motivated this design. id: compile continue-on-error: true + # A dry run compiles in this throwaway checkout; the commit step below is skipped. run: | - ARGS="--all" - if [ "${{ inputs.dry_run }}" = "true" ]; then - ARGS="$ARGS --dry-run" - fi - echo "Running: python3 tools/changelog/cli.py compile $ARGS" - python3 tools/changelog/cli.py compile $ARGS + python3 tools/changelog/cli.py compile + git diff HEAD --stat - name: Commit and push if fragments were compiled # ``always()`` lets this step run even when the compile step above # reported partial failure (one package's malformed file fails, the # rest still wrote their CHANGELOG.rst / deleted fragments / bumped - # extension.toml). The staged-diff check below short-circuits cleanly + # pyproject.toml). The staged-diff check below short-circuits cleanly # if the failed package was the only one with pending work. if: ${{ always() && !inputs.dry_run }} env: @@ -223,9 +222,7 @@ jobs: # them correctly. ID 282401363 is isaaclab-bot[bot]'s user ID. git config user.name "isaaclab-bot[bot]" git config user.email "282401363+isaaclab-bot[bot]@users.noreply.github.com" - git add source/*/changelog.d/ \ - source/*/docs/CHANGELOG.rst \ - source/*/pyproject.toml + # ``compile`` staged exactly its outputs (fragments, CHANGELOG.rst, pyproject.toml, uv.lock). if git diff --staged --quiet; then echo "No changelog fragments found — nothing to commit." else diff --git a/docs/README.md b/docs/README.md index 6e23250872e3..f8dc089b7fc3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,60 +3,28 @@ We use [Sphinx](https://www.sphinx-doc.org/en/master/) with the [Book Theme](https://sphinx-book-theme.readthedocs.io/en/stable/) for maintaining and generating our documentation. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) before continuing. -Run all commands below from the repository root. The `--isolated` option creates -a temporary environment for the `test` extra, which combines test and -documentation requirements, leaving the repository's `.venv` unchanged. +Run the build commands below from the repository root. The Isaac Lab CLI runs +Sphinx in the repository's `.venv` with the `dev` extra, syncing documentation +dependencies into that environment. The project selects Python 3.12. ## Current-Version Documentation This section describes how to build the documentation for the current version of the project. -
-Linux - ```bash -# 1. Build the current documentation -uv run --isolated --extra dev -- make -C docs current-docs - -# 2. Open the current docs -xdg-open docs/_build/current/index.html -``` -
- -
Windows - -```batch -:: 1. Build the current documentation -uv run --isolated --extra dev -- cmd /c docs\make.bat current-docs - -:: 2. Open the current docs -start docs\_build\current\index.html +uv run --extra dev isaaclab --docs ``` -
+Open `docs/_build/current/index.html` in a browser after the build completes. ## Multi-Version Documentation This section describes how to build the multi-version documentation, which includes previous tags and the main branch. -
Linux - ```bash -# 1. Build the multi-version documentation -uv run --isolated --extra dev -- make -C docs multi-docs - -# 2. Open the multi-version docs -xdg-open docs/_build/index.html +uv run --extra dev isaaclab --docs_multi ``` -
- -
Windows -```batch -:: 1. Build the multi-version documentation -uv run --isolated --extra dev -- cmd /c docs\make.bat multi-docs - -:: 2. Open the multi-version docs -start docs\_build\index.html -``` -
+This build requires the Git tags for the versions to include. It checks for +`v3.0.0-EA/index.html` by default; set `DOCS_DEFAULT_REF` to another built ref +if needed. Open `docs/_build/index.html` in a browser after the build completes. diff --git a/docs/conf.py b/docs/conf.py index bfd4d902f694..c368f336029a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -192,7 +192,10 @@ def _read_pinned_versions() -> dict: # pinned to the release version because /docs/stable/objects.inv currently 404s "torch": (f"https://docs.pytorch.org/docs/{torch_docs_version}/", None), # Versioned documentation can lag a newly published Isaac Sim package release. - "isaacsim": ("https://docs.isaacsim.omniverse.nvidia.com/latest/py/", None), + "isaacsim": ( + "https://docs.isaacsim.omniverse.nvidia.com/latest/py/", + "https://docs.isaacsim.omniverse.nvidia.com/latest/py/objects.inv", + ), "gymnasium": ("https://gymnasium.farama.org/", None), # NOTE: pinned to /stable/ because /objects.inv at the root currently 404s "warp": ("https://nvidia.github.io/warp/stable/", None), diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 676a3abc67d6..000000000000 --- a/docs/make.bat +++ /dev/null @@ -1,65 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file to build Sphinx documentation - -set SOURCEDIR=. -set BUILDDIR=_build - -REM Check if a specific target was passed -if "%1" == "multi-docs" ( - REM Check if SPHINXBUILD is set, if not default to sphinx-multiversion - if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-multiversion - ) - where %SPHINXBUILD% >NUL 2>NUL - if errorlevel 1 ( - echo. - echo.The 'sphinx-multiversion' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-multiversion' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 - ) - %SPHINXBUILD% %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - - REM Copy the redirect index.html to the build directory - copy _redirect\index.html %BUILDDIR%\index.html - goto end -) - -if "%1" == "current-docs" ( - REM Check if SPHINXBUILD is set, if not default to sphinx-build - if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build - ) - where %SPHINXBUILD% >NUL 2>NUL - if errorlevel 1 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 - ) - if exist "%BUILDDIR%\current" rmdir /s /q "%BUILDDIR%\current" - %SPHINXBUILD% -W "%SOURCEDIR%" "%BUILDDIR%\current" %SPHINXOPTS% - goto end -) - -REM If no valid target is passed, show usage instructions -echo. -echo.Usage: -echo. make.bat multi-docs - To build the multi-version documentation. -echo. make.bat current-docs - To build the current documentation. -echo. - -:end -popd diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index 93ca9f4a2c14..79dbdd18f99f 100644 --- a/docs/source/api/lab/isaaclab.sim.spawners.rst +++ b/docs/source/api/lab/isaaclab.sim.spawners.rst @@ -301,7 +301,6 @@ Materials RigidBodyMaterialBaseCfg RigidBodyMaterialFragment UsdPhysicsRigidBodyMaterialCfg - RigidBodyMaterialCfg DeformableMaterialFragment OmniPhysicsDeformableMaterialCfg OmniPhysicsSurfaceDeformableMaterialCfg @@ -371,9 +370,11 @@ Physical Materials :show-inheritance: :exclude-members: __init__, func -.. autoclass:: RigidBodyMaterialCfg - :members: - :exclude-members: __init__, func +.. deprecated:: 3.0.0 + + ``RigidBodyMaterialCfg`` is an alias for + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. + Use the PhysX configuration directly. .. autoclass:: DeformableMaterialFragment :members: diff --git a/docs/source/refs/changelog.rst b/docs/source/refs/changelog.rst index 9a6b77df26fc..b561ab9f9561 100644 --- a/docs/source/refs/changelog.rst +++ b/docs/source/refs/changelog.rst @@ -17,5 +17,6 @@ framework releases. * :isaaclab-source:`isaaclab_ppisp ` * :isaaclab-source:`isaaclab_rl ` * :isaaclab-source:`isaaclab_tasks ` +* :isaaclab-source:`isaaclab_tasks_experimental ` * :isaaclab-source:`isaaclab_teleop ` * :isaaclab-source:`isaaclab_visualizers ` diff --git a/docs/source/refs/contributing.rst b/docs/source/refs/contributing.rst index b7d2f00c1955..cade8d1eb818 100644 --- a/docs/source/refs/contributing.rst +++ b/docs/source/refs/contributing.rst @@ -89,9 +89,8 @@ the externally hosted file from the documentation. .. caution:: Install `uv `__ before building - the documentation. The build command creates a temporary environment for the - ``dev`` extra, which includes documentation requirements, leaving the - repository's ``.venv`` unchanged. + the documentation. The build command syncs the ``dev`` extra, which includes + documentation requirements, into the repository's ``.venv``. To build the documentation, run the following command from the repository root. It installs @@ -99,29 +98,11 @@ the documentation packages and builds the current version: .. code:: bash - uv run isaaclab --docs + uv run --extra dev isaaclab --docs -The documentation is generated in the ``docs/_build`` directory. To view the documentation, open -the ``index.html`` file in ``docs/_build/current``. This can be done by running the following command -in the terminal: - -.. code:: bash - - xdg-open docs/_build/current/index.html - -.. hint:: - - The ``xdg-open`` command is used to open the ``index.html`` file in the default browser. If you are - using a different operating system, you can use the appropriate command to open the file in the browser. - - -For PR validation, remove the generated HTML before building so deleted pages cannot leave stale output. -Run these commands from the repository root; they preserve the Sphinx cache: - -.. code:: bash - - uv run python -c "import shutil; shutil.rmtree('docs/_build/current', ignore_errors=True)" - uv run isaaclab --docs +The documentation is generated in ``docs/_build/current``. Open +``docs/_build/current/index.html`` in a browser to view it. Each build clears the current +HTML output and its Sphinx cache so deleted pages and cached warnings cannot be carried over. Contributing assets @@ -160,15 +141,16 @@ package version. .. note:: - ``CHANGELOG.rst`` and the package version in ``pyproject.toml`` are compiled by CI from per-PR **fragment - files** — contributors do not edit them directly. For every package your PR touches - in ``source//`` (outside ``changelog.d/``), add one fragment under - ``source//changelog.d/..rst``: + ``CHANGELOG.rst`` and the package version in ``pyproject.toml`` are compiled nightly by CI from + per-PR `towncrier `__ **fragment files** — contributors do not + edit them directly. For every package your PR touches in ``source//`` (outside + ``changelog.d/``), add fragments under ``source//changelog.d/``: - * ``.rst`` — patch bump - * ``.minor.rst`` — minor bump (new public API) - * ``.major.rst`` — major bump (breaking change) - * ``.skip`` — no entry, no bump (CI / docs / test-only PRs) + * ``..rst`` — one file per entry type, where ```` is ``added``, ``changed``, + ``deprecated``, ``removed`` or ``fixed``; the file holds that section's bullets. + * ``.minor`` or ``.major`` — an empty file that raises the version bump from patch + to minor (new public API) or major (breaking change). + * ``.skip`` — an empty file for no entry and no bump (CI / docs / test-only PRs). ```` is any short, unique name; your branch name with ``/`` replaced by ``-`` is the recommended default. Within a batch the highest tier wins for the package. @@ -181,15 +163,15 @@ been made between each release of a package. This is a *MUST* for every release- For each fragment, please follow the following guidelines: -* Each fragment is divided into subsections based on the type of changes made. +* Each fragment's ```` names the changelog section its bullets appear under. - * ``Added``: For new features. - * ``Changed``: For changes in existing functionality. - * ``Deprecated``: For soon-to-be removed features. - * ``Removed``: For now removed features. - * ``Fixed``: For any bug fixes. + * ``added``: For new features. + * ``changed``: For changes in existing functionality. + * ``deprecated``: For soon-to-be removed features. + * ``removed``: For now removed features. + * ``fixed``: For any bug fixes. -* Each change is described in its corresponding sub-section with a bullet point. +* Each change is described with a ``*`` bullet point; continuation lines are indented. * Prefix breaking changes with **Breaking:** and provide migration guidance for deprecated, changed, or removed behavior that requires callers to adapt. * The bullet points are written in the **past tense**. @@ -203,13 +185,10 @@ For each fragment, please follow the following guidelines: When in doubt, please check the style in the existing changelog files and follow the same style. -For example, ``source/isaaclab/changelog.d/fix-partial-reset.rst``: +For example, ``source/isaaclab/changelog.d/fix-partial-reset.fixed.rst``: .. code:: rst - Fixed - ^^^^^ - * Fixed contact sensor reset behavior when only a subset of environments was reset. diff --git a/skills/developer/changelog-fragments/SKILL.md b/skills/developer/changelog-fragments/SKILL.md index edece07cd163..961e430262c1 100644 --- a/skills/developer/changelog-fragments/SKILL.md +++ b/skills/developer/changelog-fragments/SKILL.md @@ -18,7 +18,7 @@ Do not use this skill for pure docs, CI, tools, or skills changes unless they al ## Workflow 1. Identify each changed package under `source/`. -2. Add one fragment per touched package under `source//changelog.d/`. +2. Add fragments for each touched package under `source//changelog.d/`. 3. Follow the contribution guide's [changelog section](../../../docs/source/refs/contributing.rst#maintaining-package-changelogs-and-versions) for fragment tiers, entry format, breaking-change labels, and migration guidance. @@ -38,11 +38,11 @@ uv run isaaclab -f ## Maintenance -Keep this skill synchronized with `AGENTS.md`, `docs/source/refs/contributing.rst`, and `tools/changelog/cli.py`. If changelog policy changes, update those authoritative sources first and keep this skill focused on routing agents to the right workflow. +Keep this skill synchronized with `AGENTS.md`, `docs/source/refs/contributing.rst`, and `tools/changelog/`. If changelog policy changes, update those authoritative sources first and keep this skill focused on routing agents to the right workflow. ## References - [Contributing guide](../../../docs/source/refs/contributing.rst) - [Changelog tool](../../../tools/changelog/cli.py) -- [Integration examples](../../../tools/changelog/test/integration/README.md) +- [Towncrier config](../../../tools/changelog/towncrier.toml) - [Examples](examples.md) diff --git a/skills/developer/changelog-fragments/examples.md b/skills/developer/changelog-fragments/examples.md index ece55bae6f83..d1fdfd47788e 100644 --- a/skills/developer/changelog-fragments/examples.md +++ b/skills/developer/changelog-fragments/examples.md @@ -8,26 +8,20 @@ ## Patch Fragment -Use `source/isaaclab/changelog.d/.rst` for a bug fix: +Use `source/isaaclab/changelog.d/.fixed.rst` for a bug fix: ```rst -Fixed -^^^^^ - * Fixed contact sensor reset behavior when environments are partially reset. ``` ## Minor Fragment -Use `source/isaaclab/changelog.d/.minor.rst` for a new public feature: +Use `source/isaaclab/changelog.d/.added.rst` plus an empty `.minor` for a new public feature: ```rst -Added -^^^^^ - * Added :class:`~isaaclab.sensors.ExampleSensor` for configurable example sensing. ``` ## Skip Fragment -Use `source/isaaclab/changelog.d/.skip` when a `source//` change is test-only and has no user-facing release note. +Use an empty `source/isaaclab/changelog.d/.skip` when a `source//` change is test-only and has no user-facing release note. diff --git a/tools/changelog/test/invalid_content/3001.rst b/source/isaaclab/changelog.d/fix-pr8105-ci.skip similarity index 100% rename from tools/changelog/test/invalid_content/3001.rst rename to source/isaaclab/changelog.d/fix-pr8105-ci.skip diff --git a/source/isaaclab/changelog.d/perf-hot-path-7b-managers.added.rst b/source/isaaclab/changelog.d/perf-hot-path-7b-managers.added.rst new file mode 100644 index 000000000000..02489a28ec46 --- /dev/null +++ b/source/isaaclab/changelog.d/perf-hot-path-7b-managers.added.rst @@ -0,0 +1,3 @@ +* Added :func:`~isaaclab.utils.math.sample_uniform_from_ranges` to sample named components with + shared, bounded caching of device bounds. Core and Lift event terms now use this sampler instead + of maintaining separate tensor-cache helpers. diff --git a/source/isaaclab/changelog.d/perf-hot-path-7b-managers.changed.rst b/source/isaaclab/changelog.d/perf-hot-path-7b-managers.changed.rst new file mode 100644 index 000000000000..a02ea3bcfefb --- /dev/null +++ b/source/isaaclab/changelog.d/perf-hot-path-7b-managers.changed.rst @@ -0,0 +1,23 @@ +* **Breaking:** Required noise functions and models configured through + :class:`~isaaclab.utils.noise.NoiseCfg` and :class:`~isaaclab.utils.noise.NoiseModelCfg` + to leave their input unchanged. :class:`~isaaclab.managers.ObservationManager` now calls + them without a defensive input copy. Custom callbacks using in-place operations must + clone their input first (for example, ``data.clone().add_(bias)``) or use out-of-place + operations (``data + bias``). Returning the unchanged input or a view remains supported; + the manager still protects borrowed outputs during subsequent processing. + +* Changed episode logging in :class:`~isaaclab.managers.CommandTerm`, + :class:`~isaaclab.managers.TerminationManager`, :class:`~isaaclab.managers.CurriculumManager`, + :class:`~isaaclab.envs.mdp.UniformPoseCommand`, :class:`~isaaclab.envs.mdp.UniformPose2dCommand` and + :class:`~isaaclab.envs.mdp.survival_success_rate` to report 0-d device tensors instead of Python floats, + matching :class:`~isaaclab.managers.RewardManager`, so resets no longer synchronize the stream. Code that + reads these ``extras["log"]`` entries as floats should call ``float()`` or ``.item()`` on them. +* Removed per-step host synchronizations from the heading control of + :class:`~isaaclab.envs.mdp.UniformVelocityCommand` and from + :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction`. +* Cached the device range tensors of the root-state, nodal-state, push and center-of-mass event terms by + value instead of uploading them on every call. +* Batched the per-term updates of :class:`~isaaclab.managers.RewardManager`, and skipped the defensive copy + before noise callbacks and after delay buffers in :class:`~isaaclab.managers.ObservationManager`. +* Shifted :class:`~isaaclab.utils.buffers.CircularBuffer` histories of small frames in two kernels + regardless of the history length. diff --git a/source/isaaclab/changelog.d/perf-hot-path-7b-managers.fixed.rst b/source/isaaclab/changelog.d/perf-hot-path-7b-managers.fixed.rst new file mode 100644 index 000000000000..035066f12709 --- /dev/null +++ b/source/isaaclab/changelog.d/perf-hot-path-7b-managers.fixed.rst @@ -0,0 +1,2 @@ +* Fixed :class:`~isaaclab.envs.mdp.reset_root_state_uniform` ignoring the ``pose_range`` and ``velocity_range`` + passed at call time, including curriculum updates, in favor of the ranges present at construction. diff --git a/source/isaaclab/changelog.d/perf-hot-path-7b-managers.major b/source/isaaclab/changelog.d/perf-hot-path-7b-managers.major new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/changelog.d/perf-hot-path-7b-newton.changed.rst b/source/isaaclab/changelog.d/perf-hot-path-7b-newton.changed.rst new file mode 100644 index 000000000000..497968ebd7f2 --- /dev/null +++ b/source/isaaclab/changelog.d/perf-hot-path-7b-newton.changed.rst @@ -0,0 +1,3 @@ +* Changed :meth:`~isaaclab.utils.wrench_composer.WrenchComposer.reset` to return without launching work + when the composer is inactive, removing the per-step buffer clears for rigid objects and collections + that apply no external wrench. diff --git a/source/isaaclab/changelog.d/perf-hot-path-7b-sensors.changed.rst b/source/isaaclab/changelog.d/perf-hot-path-7b-sensors.changed.rst new file mode 100644 index 000000000000..ae3c0c261c44 --- /dev/null +++ b/source/isaaclab/changelog.d/perf-hot-path-7b-sensors.changed.rst @@ -0,0 +1,8 @@ +* Changed sensors with ``debug_vis=True`` to refresh their buffers lazily from the debug + visualization callback instead of on every :meth:`~isaaclab.sensors.SensorBase.update`. + Custom sensors must refresh outdated buffers (for example through ``data``) in their + ``_debug_vis_callback`` before reading internal data. +* Skipped ray-caster drift sampling on reset when ``drift_range`` and ``ray_cast_drift_range`` + are zero, and uploaded the ray-cast drift ranges to the device only when they change. +* Changed :meth:`~isaaclab.markers.VisualizationMarkers.visualize` to return without validating + or processing inputs when no visualizer backend is active, such as in headless runs. diff --git a/source/isaaclab/changelog.d/perf-hot-path-7b-sensors.fixed.rst b/source/isaaclab/changelog.d/perf-hot-path-7b-sensors.fixed.rst new file mode 100644 index 000000000000..db5024f05538 --- /dev/null +++ b/source/isaaclab/changelog.d/perf-hot-path-7b-sensors.fixed.rst @@ -0,0 +1 @@ +* Cleared previously sampled ray-caster drift on reset when its configured range was changed to zero. diff --git a/source/isaaclab/changelog.d/towncrier-migration.skip b/source/isaaclab/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/changelog.d/windows-uv-docs.added.rst b/source/isaaclab/changelog.d/windows-uv-docs.added.rst new file mode 100644 index 000000000000..88a2ed542af9 --- /dev/null +++ b/source/isaaclab/changelog.d/windows-uv-docs.added.rst @@ -0,0 +1 @@ +* Added a cross-platform CLI command for building multi-version documentation with uv using ``isaaclab --docs_multi``. diff --git a/source/isaaclab/changelog.d/windows-uv-docs.fixed.rst b/source/isaaclab/changelog.d/windows-uv-docs.fixed.rst new file mode 100644 index 000000000000..972828f2b584 --- /dev/null +++ b/source/isaaclab/changelog.d/windows-uv-docs.fixed.rst @@ -0,0 +1,4 @@ +* Fixed the Windows documentation instructions to use the uv-backed CLI instead of the batch build script. +* Reused the repository environment for documentation dependencies to avoid a second full installation. +* Cleared current documentation output and its Sphinx cache before building to avoid stale pages and warnings. +* Reported a missing multi-version redirect target as a CLI error with recovery guidance. diff --git a/source/isaaclab/changelog.d/windows-uv-docs.minor b/source/isaaclab/changelog.d/windows-uv-docs.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 05cd0a36f70c..d9c1e4200f4b 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 25.1.0 (2026-09-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/cli/__init__.py b/source/isaaclab/isaaclab/cli/__init__.py index 2a2eeda4690d..226793474384 100644 --- a/source/isaaclab/isaaclab/cli/__init__.py +++ b/source/isaaclab/isaaclab/cli/__init__.py @@ -319,6 +319,11 @@ def cli() -> None: action="store_true", help="Build the documentation from source using sphinx.", ) + parser.add_argument( + "--docs_multi", + action="store_true", + help="Build the multi-version documentation from source using sphinx-multiversion.", + ) parser.add_argument( "-n", "--new", @@ -377,6 +382,9 @@ def cli() -> None: elif args.docs: command_build_docs() + elif args.docs_multi: + command_build_docs(multi_version=True) + elif args.docker is not None: command_run_docker(args.docker) diff --git a/source/isaaclab/isaaclab/cli/commands/misc.py b/source/isaaclab/isaaclab/cli/commands/misc.py index 0aa217ed40ce..538bb697c6bb 100644 --- a/source/isaaclab/isaaclab/cli/commands/misc.py +++ b/source/isaaclab/isaaclab/cli/commands/misc.py @@ -6,6 +6,7 @@ """Misc commands""" import argparse +import os import platform import shutil import sys @@ -82,8 +83,13 @@ def command_editor(editor_args: list[str], project_dir: Path | None = None) -> N parser.error(str(error)) -def command_build_docs() -> None: - """Build the documentation.""" +def command_build_docs(multi_version: bool = False) -> None: + """Build the documentation using the repository's uv environment. + + Args: + multi_version: Build all selected Git refs and a root redirect instead of the current checkout. + ``DOCS_DEFAULT_REF`` selects the redirect target, defaulting to ``v3.0.0-EA``. + """ print_info("Building documentation...") docs_dir = ISAACLAB_ROOT / "docs" @@ -93,34 +99,43 @@ def command_build_docs() -> None: print_error("https://docs.astral.sh/uv/getting-started/installation/") raise SystemExit(1) - out_dir = docs_dir / "_build" / "current" - cmd = [ - uv_exe, - "run", - "--isolated", - "--extra", - "dev", - "--", - "python", - "-m", - "sphinx", - "-W", - "--keep-going", - "-j", - "auto", - "-b", - "html", - "-d", - "_build/doctrees", - ".", - str(out_dir), - ] - run_command(cmd, cwd=docs_dir) + out_dir = docs_dir / "_build" + cmd = [uv_exe, "run", "--extra", "dev", "--"] + if multi_version: + default_ref = os.getenv("DOCS_DEFAULT_REF", "v3.0.0-EA") + run_command(cmd + ["sphinx-multiversion", ".", str(out_dir), "--jobs=auto"], cwd=docs_dir) + if not (out_dir / default_ref / "index.html").is_file(): + print_error(f"Default docs ref '{default_ref}' was not built. Fetch the Git refs or set DOCS_DEFAULT_REF.") + raise SystemExit(1) + template = (docs_dir / "_redirect" / "index.html").read_text(encoding="utf-8") + (out_dir / "index.html").write_text(template.replace("__DOCS_DEFAULT_REF__", default_ref), encoding="utf-8") + else: + out_dir /= "current" + if out_dir.exists(): + shutil.rmtree(out_dir) + run_command( + cmd + + [ + "python", + "-m", + "sphinx", + "-W", + "--keep-going", + "-j", + "auto", + "-b", + "html", + "-d", + str(out_dir / ".doctrees"), + ".", + str(out_dir), + ], + cwd=docs_dir, + ) index_path = out_dir / "index.html" print_info(f"Documentation built at {index_path}") - if not is_windows(): - print_info(f"Open with: xdg-open {index_path}") + print_info(f"Open {index_path} in a browser.") def command_build_isaacsim(source_path: str) -> None: diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index 778023f6a58b..b9c88d97f186 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -546,7 +546,7 @@ def step(self, action: torch.Tensor) -> tuple[VecEnvObs, dict]: self.recorder_manager.record_pre_step() # check if we need to do rendering within the physics loop - # note: uses cached property to avoid settings lookup every step + # note: evaluated once per step rather than once per physics substep is_rendering = self.sim.is_rendering # physics-owned decimation covers all substeps in one call @@ -562,7 +562,7 @@ def step(self, action: torch.Tensor) -> tuple[VecEnvObs, dict]: self.scene.update(dt=self.physics_dt * steps_per_call) # post-step: step interval event - if "interval" in self.event_manager.available_modes: + if "interval" in self.event_manager.active_terms: self.event_manager.apply(mode="interval", dt=self.step_dt) # advance video recorders (after render, before obs) @@ -631,7 +631,7 @@ def _reset_idx(self, env_ids: torch.Tensor | slice): self.scene.reset(env_ids) # apply events such as randomization for environments that need a reset - if "reset" in self.event_manager.available_modes: + if "reset" in self.event_manager.active_terms: env_step_count = self._sim_step_counter // self.cfg.decimation self.event_manager.apply(mode="reset", env_ids=env_ids, global_env_step_count=env_step_count) diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 750c2af87b96..dfc59587562e 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -207,7 +207,7 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: self.recorder_manager.record_pre_step() # check if we need to do rendering within the physics loop - # note: uses cached property to avoid settings lookup every step + # note: evaluated once per step rather than once per physics substep is_rendering = self.sim.is_rendering # physics-owned decimation covers all substeps in one call @@ -269,7 +269,7 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: self.recorder_manager.record_post_reset(manual_reset_ids) self.command_manager.compute(dt=self.step_dt) - if "interval" in self.event_manager.available_modes: + if "interval" in self.event_manager.active_terms: self.event_manager.apply(mode="interval", dt=self.step_dt) # -- advance video recorders (after render and resets, before final obs) for recorder in self.video_recorders: @@ -387,7 +387,7 @@ def _reset_idx(self, env_ids: torch.Tensor | slice): # reset the internal buffers of the scene elements self.scene.reset(env_ids) # apply events such as randomizations for environments that need a reset - if "reset" in self.event_manager.available_modes: + if "reset" in self.event_manager.active_terms: env_step_count = self._sim_step_counter // self.cfg.decimation self.event_manager.apply(mode="reset", env_ids=env_ids, global_env_step_count=env_step_count) diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py index 3b077356cea5..6d039894c528 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py @@ -210,11 +210,9 @@ def apply_actions(self): self._ik_controller.set_joint_pos_limits(limits[:, 0].clone(), limits[:, 1].clone()) self._limits_injected = True # compute the delta in joint-space - if ee_quat_curr.norm() != 0: - jacobian = self._compute_frame_jacobian() - joint_pos_des = self._ik_controller.compute(ee_pos_curr, ee_quat_curr, jacobian, joint_pos) - else: - joint_pos_des = joint_pos.clone() + jacobian = self._compute_frame_jacobian() + joint_pos_des = self._ik_controller.compute(ee_pos_curr, ee_quat_curr, jacobian, joint_pos) + joint_pos_des = torch.where(ee_quat_curr.norm() != 0, joint_pos_des, joint_pos) # set the joint position command self._asset.set_joint_position_target_index(target=joint_pos_des, joint_ids=self._joint_ids) diff --git a/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py b/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py index f1b0f747e341..ae9cd040bcdb 100644 --- a/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py +++ b/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py @@ -98,16 +98,14 @@ def _update_metrics(self): if self._track_success: self._succeeded |= self.metrics["error_pos"] < self.cfg.position_success_threshold - def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: + def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]: extras = super().reset(env_ids) if self._track_success: if env_ids is None: env_ids = slice(None) # Write the unified ``Metrics/success_rate`` directly to env extras so it shares # a TensorBoard card with the same metric from other tasks. - self._env.extras.setdefault("log", {})["Metrics/success_rate"] = ( - self._succeeded[env_ids].float().mean().item() - ) + self._env.extras.setdefault("log", {})["Metrics/success_rate"] = self._succeeded[env_ids].float().mean() index_fill_(self._succeeded, env_ids, False) return extras diff --git a/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py b/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py index 74b329f2ed2a..89ef3e5a5258 100644 --- a/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py +++ b/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py @@ -151,16 +151,14 @@ def _compute_success(self, position_error: torch.Tensor, orientation_error: torc success[:] = False return success - def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: + def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]: extras = super().reset(env_ids) if self._track_success: if env_ids is None: env_ids = slice(None) # Write the unified ``Metrics/success_rate`` directly to env extras so it shares # a TensorBoard card with the same metric from other tasks. - self._env.extras.setdefault("log", {})["Metrics/success_rate"] = ( - self._succeeded[env_ids].float().mean().item() - ) + self._env.extras.setdefault("log", {})["Metrics/success_rate"] = self._succeeded[env_ids].float().mean() index_fill_(self._succeeded, env_ids, False) return extras diff --git a/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py b/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py index becf01280f75..f580e87d3052 100644 --- a/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py +++ b/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py @@ -130,7 +130,7 @@ def _update_metrics(self): self._error_yaw_sum += error_yaw self._step_count += 1.0 - def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: + def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]: # Finalize the just-ended episode's metrics into ``self.metrics`` BEFORE the base # class reads them. ``success_rate`` is per-env binary: the *episode-mean* error # is below both thresholds. Then super().reset() logs and zeros ``self.metrics``; @@ -181,17 +181,13 @@ def _update_command(self): """ # Compute angular velocity from heading direction if self.cfg.heading_command: - # resolve indices of heading envs - env_ids = self.is_heading_env.nonzero(as_tuple=False).flatten() - # compute angular velocity - heading_error = math_utils.wrap_to_pi( - self.heading_target[env_ids] - self.robot.data.heading_w.torch[env_ids] - ) - self.vel_command_b[env_ids, 2] = torch.clip( + heading_error = math_utils.wrap_to_pi(self.heading_target - self.robot.data.heading_w.torch) + heading_ang_vel = torch.clip( self.cfg.heading_control_stiffness * heading_error, min=self.cfg.ranges.ang_vel_z[0], max=self.cfg.ranges.ang_vel_z[1], ) + self.vel_command_b[:, 2] = torch.where(self.is_heading_env, heading_ang_vel, self.vel_command_b[:, 2]) # Enforce standing (i.e., zero velocity command) for standing envs index_fill_(self.vel_command_b, self.is_standing_env, 0.0) diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index eeac316a79ca..59309db504e7 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -522,11 +522,9 @@ def __call__( env_rows = env_ids[:, None] if not isinstance(env_ids, slice) and not isinstance(body_ids, slice) else env_ids # sample random CoM values - range_list = [com_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z"]] - ranges = torch.tensor(range_list, device=self.asset.device) num_envs = len(range(env.num_envs)[env_ids]) if isinstance(env_ids, slice) else len(env_ids) - rand_samples = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (num_envs, 3), device=self.asset.device + rand_samples = math_utils.sample_uniform_from_ranges( + com_range, ("x", "y", "z"), num_envs, device=self.asset.device ).unsqueeze(1) # start from defaults and add random offsets @@ -1257,9 +1255,9 @@ def push_by_setting_velocity( # velocities vel_w = asset.data.root_vel_w.torch[env_ids] # sample random velocities - range_list = [velocity_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"]] - ranges = torch.tensor(range_list, device=asset.device) - vel_w += math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], vel_w.shape, device=asset.device) + vel_w += math_utils.sample_uniform_from_ranges( + velocity_range, ("x", "y", "z", "roll", "pitch", "yaw"), vel_w.shape[:-1], device=asset.device + ) # set the velocities into the physics simulation asset.write_root_velocity_to_sim_index(root_velocity=vel_w, env_ids=env_ids) @@ -1277,20 +1275,8 @@ class reset_root_state_uniform(ManagerTermBase): The term takes a dictionary of pose and velocity ranges for each axis and rotation. The keys of the dictionary are ``x``, ``y``, ``z``, ``roll``, ``pitch``, and ``yaw``. The values are tuples of the form ``(min, max)``. If the dictionary does not contain a key, the position or velocity is set to zero for that axis. - - The range dictionaries are materialized as device tensors once at construction. """ - def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): - super().__init__(cfg, env) - keys = ("x", "y", "z", "roll", "pitch", "yaw") - pose_range = cfg.params.get("pose_range", {}) - velocity_range = cfg.params.get("velocity_range", {}) - self._pose_ranges = torch.tensor([tuple(pose_range.get(key, (0.0, 0.0))) for key in keys], device=env.device) - self._velocity_ranges = torch.tensor( - [tuple(velocity_range.get(key, (0.0, 0.0))) for key in keys], device=env.device - ) - def __call__( self, env: ManagerBasedEnv, @@ -1304,18 +1290,16 @@ def __call__( default_root_pose = asset.data.default_root_pose.torch[env_ids] default_root_vel = asset.data.default_root_vel.torch[env_ids] - ranges = self._pose_ranges - rand_samples = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (default_root_pose.shape[0], 6), device=asset.device + rand_samples = math_utils.sample_uniform_from_ranges( + pose_range, ("x", "y", "z", "roll", "pitch", "yaw"), default_root_pose.shape[0], device=asset.device ) positions = default_root_pose[:, 0:3] + env.scene.env_origins[env_ids] + rand_samples[:, 0:3] orientations_delta = math_utils.quat_from_euler_xyz(rand_samples[:, 3], rand_samples[:, 4], rand_samples[:, 5]) orientations = math_utils.quat_mul(default_root_pose[:, 3:7], orientations_delta) # velocities - ranges = self._velocity_ranges - rand_samples = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (default_root_pose.shape[0], 6), device=asset.device + rand_samples = math_utils.sample_uniform_from_ranges( + velocity_range, ("x", "y", "z", "roll", "pitch", "yaw"), default_root_pose.shape[0], device=asset.device ) velocities = default_root_vel + rand_samples @@ -1356,20 +1340,16 @@ def reset_root_state_with_random_orientation( default_root_vel = asset.data.default_root_vel.torch[env_ids].clone() # poses - range_list = [pose_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z"]] - ranges = torch.tensor(range_list, device=asset.device) - rand_samples = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (default_root_pose.shape[0], 3), device=asset.device + rand_samples = math_utils.sample_uniform_from_ranges( + pose_range, ("x", "y", "z"), default_root_pose.shape[0], device=asset.device ) positions = default_root_pose[:, 0:3] + env.scene.env_origins[env_ids] + rand_samples orientations = math_utils.random_orientation(default_root_pose.shape[0], device=asset.device) # velocities - range_list = [velocity_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"]] - ranges = torch.tensor(range_list, device=asset.device) - rand_samples = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (default_root_pose.shape[0], 6), device=asset.device + rand_samples = math_utils.sample_uniform_from_ranges( + velocity_range, ("x", "y", "z", "roll", "pitch", "yaw"), default_root_pose.shape[0], device=asset.device ) velocities = default_root_vel + rand_samples @@ -1426,17 +1406,17 @@ def reset_root_state_from_terrain( positions = valid_positions[terrain.terrain_levels[env_ids], terrain.terrain_types[env_ids], ids] positions += asset.data.default_root_pose.torch[env_ids, :3] # sample random orientations - range_list = [pose_range.get(key, (0.0, 0.0)) for key in ["roll", "pitch", "yaw"]] - ranges = torch.tensor(range_list, device=asset.device) - rand_samples = math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], (num_envs, 3), device=asset.device) + rand_samples = math_utils.sample_uniform_from_ranges( + pose_range, ("roll", "pitch", "yaw"), num_envs, device=asset.device + ) # convert to quaternions orientations = math_utils.quat_from_euler_xyz(rand_samples[:, 0], rand_samples[:, 1], rand_samples[:, 2]) # sample random velocities - range_list = [velocity_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"]] - ranges = torch.tensor(range_list, device=asset.device) - rand_samples = math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], (num_envs, 6), device=asset.device) + rand_samples = math_utils.sample_uniform_from_ranges( + velocity_range, ("x", "y", "z", "roll", "pitch", "yaw"), num_envs, device=asset.device + ) velocities = asset.data.default_root_vel.torch[env_ids] + rand_samples @@ -1699,19 +1679,15 @@ def reset_nodal_state_uniform( nodal_state = asset.data.default_nodal_state_w.torch[env_ids].clone() # position - range_list = [position_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z"]] - ranges = torch.tensor(range_list, device=asset.device) - rand_samples = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (nodal_state.shape[0], 1, 3), device=asset.device + rand_samples = math_utils.sample_uniform_from_ranges( + position_range, ("x", "y", "z"), (nodal_state.shape[0], 1), device=asset.device ) nodal_state[..., :3] += rand_samples # velocities - range_list = [velocity_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z"]] - ranges = torch.tensor(range_list, device=asset.device) - rand_samples = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (nodal_state.shape[0], 1, 3), device=asset.device + rand_samples = math_utils.sample_uniform_from_ranges( + velocity_range, ("x", "y", "z"), (nodal_state.shape[0], 1), device=asset.device ) nodal_state[..., 3:] += rand_samples diff --git a/source/isaaclab/isaaclab/envs/mdp/rewards.py b/source/isaaclab/isaaclab/envs/mdp/rewards.py index 127c8cd3d9b4..7fcae5494399 100644 --- a/source/isaaclab/isaaclab/envs/mdp/rewards.py +++ b/source/isaaclab/isaaclab/envs/mdp/rewards.py @@ -88,7 +88,7 @@ class survival_success_rate(ManagerTermBase): def reset(self, env_ids: torch.Tensor) -> None: survived = self._env.termination_manager.time_outs[env_ids] - self._env.extras.setdefault("log", {})["Metrics/success_rate"] = survived.float().mean().item() + self._env.extras.setdefault("log", {})["Metrics/success_rate"] = survived.float().mean() def __call__(self, env: ManagerBasedRLEnv) -> torch.Tensor: return torch.zeros(env.num_envs, device=env.device) diff --git a/source/isaaclab/isaaclab/managers/command_manager.py b/source/isaaclab/isaaclab/managers/command_manager.py index a2902beb206c..be467d0f22dc 100644 --- a/source/isaaclab/isaaclab/managers/command_manager.py +++ b/source/isaaclab/isaaclab/managers/command_manager.py @@ -112,7 +112,7 @@ def set_debug_vis(self, debug_vis: bool) -> bool: # return success return True - def reset(self, env_ids: Sequence[int] | slice | None = None) -> dict[str, float]: + def reset(self, env_ids: Sequence[int] | slice | None = None) -> dict[str, torch.Tensor]: """Reset the command generator and log metrics. This function resets the command counter and resamples the command. It should be called @@ -132,7 +132,7 @@ def reset(self, env_ids: Sequence[int] | slice | None = None) -> dict[str, float extras = {} for metric_name, metric_value in self.metrics.items(): # compute the mean metric value - extras[metric_name] = torch.mean(metric_value[env_ids]).item() + extras[metric_name] = torch.mean(metric_value[env_ids]) # reset the metric value index_fill_(metric_value, env_ids, 0.0) diff --git a/source/isaaclab/isaaclab/managers/curriculum_manager.py b/source/isaaclab/isaaclab/managers/curriculum_manager.py index 7ba550273384..76db6d0d7b58 100644 --- a/source/isaaclab/isaaclab/managers/curriculum_manager.py +++ b/source/isaaclab/isaaclab/managers/curriculum_manager.py @@ -90,7 +90,7 @@ def active_terms(self) -> list[str]: Operations. """ - def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: + def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float | torch.Tensor]: """Returns the current state of individual curriculum terms. Note: @@ -109,12 +109,12 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: # each key is a separate state to log for key, value in term_state.items(): if isinstance(value, torch.Tensor): - value = value.item() + value = value.detach().clone() extras[f"Curriculum/{term_name}/{key}"] = value else: # log directly if not a dict if isinstance(term_state, torch.Tensor): - term_state = term_state.item() + term_state = term_state.detach().clone() extras[f"Curriculum/{term_name}"] = term_state # reset all the curriculum terms for term_cfg in self._class_term_cfgs: diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index 1f7e1fbac070..134ec65d3e63 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -442,11 +442,12 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. obs = modifier.func(obs) else: obs = modifier.func(obs, **modifier.params) + # Noise callbacks must not modify their input, but may return borrowed storage. if isinstance(term_cfg.noise, noise.NoiseCfg): - obs = term_cfg.noise.func(obs.clone(), term_cfg.noise) + obs = term_cfg.noise.func(obs, term_cfg.noise) owned = False elif isinstance(term_cfg.noise, noise.NoiseModelCfg) and term_cfg.noise.func is not None: - obs = term_cfg.noise.func(obs.clone()) + obs = term_cfg.noise.func(obs) owned = False if term_cfg.clip: obs = ( @@ -462,7 +463,8 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. obs = self._group_obs_term_delay_buffer[group_name][term_name].compute( obs, update_history=update_history ) - owned = False + # the delay buffer returns storage independent of its history + owned = True # Update the history buffer if observation term has history enabled if term_cfg.history_length > 0: circular_buffer = self._group_obs_term_history_buffer[group_name][term_name] @@ -649,7 +651,6 @@ def _prepare_terms(self): # add term config to list self._group_obs_term_names[group_name].append(term_name) self._group_obs_term_cfgs[group_name].append(term_cfg) - # call function the first time to fill up dimensions obs_dims = self._prepare_term_output(group_name, term_name, term_cfg) diff --git a/source/isaaclab/isaaclab/managers/reward_manager.py b/source/isaaclab/isaaclab/managers/reward_manager.py index 8e4567d264c2..40f39db90245 100644 --- a/source/isaaclab/isaaclab/managers/reward_manager.py +++ b/source/isaaclab/isaaclab/managers/reward_manager.py @@ -56,10 +56,11 @@ def __init__(self, cfg: object, env: ManagerBasedRLEnv): # call the base class constructor (this will parse the terms config) super().__init__(cfg, env) - # prepare extra info to store individual reward term information - self._episode_sums = dict() - for term_name in self._term_names: - self._episode_sums[term_name] = torch.zeros(self.num_envs, dtype=torch.float, device=self.device) + # episodic sum of each term; the dictionary holds column views of one matrix for batched updates + self._episode_sum_buf = torch.zeros( + (self.num_envs, len(self._term_names)), dtype=torch.float, device=self.device + ) + self._episode_sums = {name: self._episode_sum_buf[:, i] for i, name in enumerate(self._term_names)} # create buffer for managing reward per environment self._reward_buf = torch.zeros(self.num_envs, dtype=torch.float, device=self.device) @@ -111,14 +112,11 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor] """ if env_ids is None: env_ids = slice(None) - extras = {} - for key in self._episode_sums.keys(): - # store information - # r_1 + r_2 + ... + r_n - episodic_sum_avg = torch.mean(self._episode_sums[key][env_ids]) - extras["Episode_Reward/" + key] = episodic_sum_avg / self._env.max_episode_length_s - # reset episodic sum - index_fill_(self._episode_sums[key], env_ids, 0.0) + # average of r_1 + r_2 + ... + r_n over the selected environments, for all terms at once + episodic_sum_avg = torch.mean(self._episode_sum_buf[env_ids], dim=0) / self._env.max_episode_length_s + extras = {"Episode_Reward/" + key: episodic_sum_avg[i] for i, key in enumerate(self._term_names)} + # reset episodic sums + index_fill_(self._episode_sum_buf, env_ids, 0.0) # reset all the reward terms for term_cfg in self._class_term_cfgs: term_cfg.func.reset(env_ids=env_ids) @@ -136,23 +134,22 @@ def compute(self, dt: float) -> torch.Tensor: Returns: The net reward signal of shape (num_envs,). """ - # reset computation - self._reward_buf[:] = 0.0 - # iterate over all the reward terms - for term_idx, (name, term_cfg) in enumerate(zip(self._term_names, self._term_cfgs)): + # compute the weighted reward of each term + for term_idx, term_cfg in enumerate(self._term_cfgs): # skip if weight is zero (kind of a micro-optimization) if term_cfg.weight == 0.0: self._step_reward[:, term_idx] = 0.0 continue - # compute term's value - value = term_cfg.func(self._env, **term_cfg.params) * term_cfg.weight * dt - # update total reward - self._reward_buf += value - # update episodic sum - self._episode_sums[name] += value - - # Update current reward for this step. - self._step_reward[:, term_idx] = value / dt + value = term_cfg.func(self._env, **term_cfg.params) + step_reward = self._step_reward[:, term_idx] + if isinstance(value, torch.Tensor) and value.shape == step_reward.shape: + torch.mul(value, term_cfg.weight, out=step_reward) + else: + # broadcast scalar values + step_reward[:] = value * term_cfg.weight + # update total reward and episodic sums with the time-step scaling + torch.sum(self._step_reward, dim=1, out=self._reward_buf).mul_(dt) + self._episode_sum_buf.add_(self._step_reward, alpha=dt) return self._reward_buf diff --git a/source/isaaclab/isaaclab/managers/termination_manager.py b/source/isaaclab/isaaclab/managers/termination_manager.py index 75f297eb314c..dad362207acf 100644 --- a/source/isaaclab/isaaclab/managers/termination_manager.py +++ b/source/isaaclab/isaaclab/managers/termination_manager.py @@ -140,11 +140,10 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor] env_ids = slice(None) # add to episode dict extras = {} - # move to host once; per-element .item() would sync per term - last_episode_done_stats = self._last_episode_dones.float().mean(dim=0).cpu() + last_episode_done_stats = self._last_episode_dones.float().mean(dim=0) for i, key in enumerate(self._term_names): # store information - extras["Episode_Termination/" + key] = last_episode_done_stats[i].item() + extras["Episode_Termination/" + key] = last_episode_done_stats[i] # reset all the termination terms for term_cfg in self._class_term_cfgs: term_cfg.func.reset(env_ids=env_ids) diff --git a/source/isaaclab/isaaclab/markers/visualization_markers.py b/source/isaaclab/isaaclab/markers/visualization_markers.py index 781305b57969..d2f8e9f55c44 100644 --- a/source/isaaclab/isaaclab/markers/visualization_markers.py +++ b/source/isaaclab/isaaclab/markers/visualization_markers.py @@ -159,9 +159,10 @@ def visualize( """Update markers in all initialized visualizer backends. .. note:: - If the markers are hidden, the function returns without updating - backend marker state. This avoids unnecessary work while debug - visualization is disabled. + If the markers are hidden or no visualizer backend is active (e.g. when + running headless), the function returns without validating inputs or + updating marker state. This avoids unnecessary work while nothing + displays the markers. Whenever updating the markers, the input arrays must have the same number of elements in the first dimension. Backends generally require @@ -206,9 +207,9 @@ def visualize( ValueError: When the function is called with all None arguments. """ self._ensure_backends_initialized() - # If markers are hidden, do not spend time normalizing or dispatching - # marker state to the active backends. - if not self.is_visible(): + # If no backend consumes markers (e.g. headless) or markers are hidden, do not spend + # time normalizing or dispatching marker state. + if not self._backends or not self.is_visible(): return norm_translations = self._to_tensor(translations, expected_width=3, name="translations") diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py index 2f1b7798ccbf..1a98a5c41fd9 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py @@ -69,6 +69,9 @@ def __init__(self, cfg: RayCasterCfg): BaseRayCaster._instance_count += 1 super().__init__(cfg) self._data = RayCasterData() + self._drift_sampled = False + self._ray_cast_drift_range_list: tuple[tuple[float, float], ...] | None = None + self._ray_cast_drift_ranges: torch.Tensor | None = None def __str__(self) -> str: """Returns: A string containing information about the instance.""" @@ -104,6 +107,15 @@ def data(self) -> RayCasterData: def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None): # reset the timers and counters super().reset(env_ids, env_mask) + # Drift buffers start at zero, so zero ranges (the defaults) need no resampling. + sample_drift = any(self.cfg.drift_range) + ray_cast_range_list = tuple( + tuple(self.cfg.ray_cast_drift_range.get(key, (0.0, 0.0))) for key in ("x", "y", "z") + ) + sample_ray_cast_drift = any(any(axis_range) for axis_range in ray_cast_range_list) + self._drift_sampled |= sample_drift or sample_ray_cast_drift + if not self._drift_sampled: + return # determine the selected batch size if env_ids is not None: num_envs_ids = len(range(self._view_count)[env_ids]) if isinstance(env_ids, slice) else len(env_ids) @@ -114,14 +126,23 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None env_ids = slice(None) num_envs_ids = self._view_count # resample drift (uses torch views for indexing) - r = torch.empty(num_envs_ids, 3, device=self.device) - self.drift.torch[env_ids] = r.uniform_(*self.cfg.drift_range) + if sample_drift: + r = torch.empty(num_envs_ids, 3, device=self.device) + self.drift.torch[env_ids] = r.uniform_(*self.cfg.drift_range) + else: + self.drift.torch[env_ids] = 0.0 # resample the ray cast drift - range_list = [self.cfg.ray_cast_drift_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z"]] - ranges = torch.tensor(range_list, device=self.device) - self.ray_cast_drift.torch[env_ids] = math_utils.sample_uniform( - ranges[:, 0], ranges[:, 1], (num_envs_ids, 3), device=self.device - ) + if sample_ray_cast_drift: + # Upload the ranges to the device only when the configuration changes. + if ray_cast_range_list != self._ray_cast_drift_range_list: + self._ray_cast_drift_range_list = ray_cast_range_list + self._ray_cast_drift_ranges = torch.tensor(ray_cast_range_list, device=self.device) + ranges = self._ray_cast_drift_ranges + self.ray_cast_drift.torch[env_ids] = math_utils.sample_uniform( + ranges[:, 0], ranges[:, 1], (num_envs_ids, 3), device=self.device + ) + else: + self.ray_cast_drift.torch[env_ids] = 0.0 """ Implementation. @@ -324,6 +345,7 @@ def _set_debug_vis_impl(self, debug_vis: bool): def _debug_vis_callback(self, event): if self._data._ray_hits_w is None: return + self._update_outdated_buffers() # drop missed rays (inf) before visualizing viz_points = wp.to_torch(self._data._ray_hits_w).reshape(-1, 3) viz_points = viz_points[~torch.isinf(viz_points).any(dim=1)] diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py index c5463f38376b..b8f5443d3510 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py @@ -446,6 +446,7 @@ def _debug_vis_callback(self, event): # Debug visualization can be toggled before ray buffers are initialized. if not hasattr(self, "ray_hits_w"): return + self._update_outdated_buffers() # drop missed rays (inf) before visualizing viz_points = self.ray_hits_w.torch.reshape(-1, 3) viz_points = viz_points[~torch.isinf(viz_points).any(dim=-1)] diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index ee06d9dfc1fc..00e443fa4753 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -206,8 +206,8 @@ def update(self, dt: float, force_recompute: bool = False): inputs=[self._is_outdated, self._timestamp, self._timestamp_last_update, dt, self.cfg.update_period], device=self._device, ) - # Update the buffers - if force_recompute or self._is_visualizing: + # Update the buffers; debug visualization refreshes lazily from its callback. + if force_recompute: self._update_outdated_buffers(force_recompute=force_recompute) @staticmethod @@ -316,6 +316,8 @@ def _debug_vis_callback(self, event): """Callback for debug visualization. This function calls the visualization objects and sets the data to visualize into them. + Sensor buffers are refreshed lazily, so implementations must refresh outdated buffers + (for example through :attr:`data`) before reading internal data. """ raise NotImplementedError(f"Debug visualization is not implemented for {self.__class__.__name__}.") diff --git a/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py b/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py index 3417ac3e38f8..985a9d5ed2ab 100644 --- a/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py +++ b/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py @@ -9,6 +9,9 @@ from ..array import index_fill_ +# Frame size up to which one staged shift is faster than K - 1 slot copies (measured crossover is ~4 MiB). +_STAGED_SHIFT_MAX_FRAME_BYTES = 2 * 1024 * 1024 + class CircularBuffer: """Circular buffer for storing a history of batched tensor data. @@ -173,15 +176,14 @@ def append(self, data: torch.Tensor): if self._buffer is None: self._allocate_buffer(data) - # Shift slots so the newest write lands at the last K slot. Iterating front-to-back - # keeps adjacent-slot copies non-overlapping. Cheap at the typical frame-stack K=2-4. - if self._stack_dim_internal is None: - for i in range(self._max_len_int - 1): - self._buffer[i].copy_(self._buffer[i + 1]) - self._buffer[-1] = data + # Drop the oldest slot and write the newest at the last K slot. + k_pos = 0 if self._stack_dim_internal is None else self._stack_dim_internal + k = self._max_len_int + if k > 2 and data.numel() * data.element_size() <= _STAGED_SHIFT_MAX_FRAME_BYTES: + # small frames are launch bound: stage the overlapping shift in two kernels regardless of K + self._buffer.copy_(torch.cat((self._buffer.narrow(k_pos, 1, k - 1), data.unsqueeze(k_pos)), dim=k_pos)) else: - k_pos = self._stack_dim_internal - k = self._max_len_int + # large frames are bandwidth bound: front-to-back slot copies move the least data for i in range(k - 1): self._buffer.narrow(k_pos, i, 1).copy_(self._buffer.narrow(k_pos, i + 1, 1)) self._buffer.narrow(k_pos, k - 1, 1).copy_(data.unsqueeze(k_pos)) diff --git a/source/isaaclab/isaaclab/utils/math.py b/source/isaaclab/isaaclab/utils/math.py index eff9e22d576d..c3366a3d8f81 100644 --- a/source/isaaclab/isaaclab/utils/math.py +++ b/source/isaaclab/isaaclab/utils/math.py @@ -8,8 +8,10 @@ # needed to import for allowing type-hinting: torch.Tensor | np.ndarray from __future__ import annotations +import functools import logging import math +from collections.abc import Sequence from typing import Literal import numpy as np @@ -1400,6 +1402,40 @@ def sample_uniform( return torch.rand(*size, device=device) * (upper - lower) + lower +def sample_uniform_from_ranges( + ranges: dict[str, tuple[float, float]], + keys: Sequence[str], + size: int | tuple[int, ...], + device: str | torch.device, +) -> torch.Tensor: + """Sample independent components uniformly from named ranges. + + Missing keys use zero bounds. Device bounds are cached by value, so edits to + ``ranges`` take effect on the next call without repeated uploads of unchanged bounds. + Only bounds are cached; each call draws fresh samples in the order given by ``keys``. + + Args: + ranges: Lower and upper bounds for each named component. + keys: Component names, in output order. + size: Batch shape, excluding the component dimension. + device: Device to create the samples on. + + Returns: + Sampled tensor of shape ``(*size, len(keys))`` using the default floating-point dtype. + """ + if isinstance(size, int): + size = (size,) + device = torch.device(device) + if device.type == "cuda" and device.index is None: + device = torch.device("cuda", torch.cuda.current_device()) + bounds = [] + for key in keys: + lower, upper = ranges.get(key, (0.0, 0.0)) + bounds.append((float(lower), float(upper))) + limits = _cached_uniform_bounds(tuple(bounds), device, torch.get_default_dtype()) + return sample_uniform(limits[:, 0], limits[:, 1], (*size, len(keys)), device) + + def sample_log_uniform( lower: torch.Tensor | float, upper: torch.Tensor | float, size: int | tuple[int, ...], device: str ) -> torch.Tensor: @@ -1975,3 +2011,11 @@ def generate_random_transformation_matrix(pos_boundary: float = 1, rot_boundary: T[:3, 3] = translation return T + + +@functools.lru_cache(maxsize=128) +def _cached_uniform_bounds( + bounds: tuple[tuple[float, float], ...], device: torch.device, dtype: torch.dtype +) -> torch.Tensor: + """Keep shared device bounds private so callers cannot mutate cached tensors.""" + return torch.tensor(bounds, dtype=dtype, device=device).reshape(-1, 2) diff --git a/source/isaaclab/isaaclab/utils/noise/noise_cfg.py b/source/isaaclab/isaaclab/utils/noise/noise_cfg.py index da76e38b7212..63c7329c0d53 100644 --- a/source/isaaclab/isaaclab/utils/noise/noise_cfg.py +++ b/source/isaaclab/isaaclab/utils/noise/noise_cfg.py @@ -19,14 +19,10 @@ @configclass class NoiseCfg: - """Base configuration for a noise term.""" + """Base configuration for noise that does not modify its input.""" func: Callable[[torch.Tensor, NoiseCfg], torch.Tensor] = MISSING - """The function to be called for applying the noise. - - Note: - The shape of the input and output tensors must be the same. - """ + """Function that applies noise without changing its input or shape.""" operation: Literal["add", "scale", "abs"] = "add" """The operation to apply the noise on the data. Defaults to "add".""" @@ -72,7 +68,7 @@ class GaussianNoiseCfg(NoiseCfg): @configclass class NoiseModelCfg: - """Configuration for a noise model.""" + """Configuration for a noise model that does not modify its input.""" class_type: type[NoiseModel] | str = "{DIR}.noise_model:NoiseModel" """The class type of the noise model.""" diff --git a/source/isaaclab/isaaclab/utils/noise/noise_model.py b/source/isaaclab/isaaclab/utils/noise/noise_model.py index f3ad251bd508..ff150d304ea3 100644 --- a/source/isaaclab/isaaclab/utils/noise/noise_model.py +++ b/source/isaaclab/isaaclab/utils/noise/noise_model.py @@ -120,7 +120,9 @@ def reset(self, env_ids: Sequence[int] | None = None): pass def __call__(self, data: torch.Tensor) -> torch.Tensor: - """Apply the noise to the data. + """Apply the noise without modifying the input data. + + Implementations must use out-of-place operations or clone the input before modifying it. Args: data: The data to apply the noise to. Shape is (num_envs, ...). diff --git a/source/isaaclab/isaaclab/utils/wrench_composer.py b/source/isaaclab/isaaclab/utils/wrench_composer.py index 4d859090d409..6331716cad5d 100644 --- a/source/isaaclab/isaaclab/utils/wrench_composer.py +++ b/source/isaaclab/isaaclab/utils/wrench_composer.py @@ -598,7 +598,7 @@ def reset( With no selection or ``env_ids=slice(None)``, zeros all seven buffers (5 input + 2 output) and clears all flags. Other ``env_ids`` or ``env_mask`` values perform a partial reset on the specified environments using - the reset kernels. + the reset kernels. An inactive composer already holds zero buffers, so any reset of it is a no-op. .. caution:: If both ``env_ids`` and ``env_mask`` are provided, ``env_mask`` takes precedence. @@ -606,6 +606,8 @@ def reset( env_ids: Environment indices. Defaults to None (all environments). env_mask: Environment mask. Defaults to None (all environments). """ + if not self._active: + return full_reset = env_mask is None and (env_ids is None or (isinstance(env_ids, slice) and env_ids == slice(None))) if full_reset: # Full reset: zero all 7 buffers diff --git a/source/isaaclab/test/assets/_articulation_iface_test_utils.py b/source/isaaclab/test/assets/_articulation_iface_test_utils.py index 25d10a44ee6e..d852c5f13e35 100644 --- a/source/isaaclab/test/assets/_articulation_iface_test_utils.py +++ b/source/isaaclab/test/assets/_articulation_iface_test_utils.py @@ -115,6 +115,7 @@ def create_physx_articulation( object.__setattr__(articulation, "_root_view", mock_view) object.__setattr__(articulation, "_device", device) + articulation._clamped_default_count = wp.zeros(1, dtype=wp.int32, device=device) # We can't call the initialize method here, because we don't have a good mock for the actuators yet. # We need to set the _data attribute manually. @@ -392,6 +393,7 @@ def create_newton_articulation( object.__setattr__(articulation, "_device", device) object.__setattr__(articulation, "_data", data) object.__setattr__(articulation, "_test_simulation_manager", mock_manager) + articulation._clamped_default_count = wp.zeros(1, dtype=wp.int32, device=device) # the solver builds this adapter; the shell has no model, so it stays absent object.__setattr__(articulation, "_fixed_tendon_control", None) diff --git a/source/isaaclab/test/cli/test_misc_commands.py b/source/isaaclab/test/cli/test_misc_commands.py index af6f10f9edb3..f71e16fb7e60 100644 --- a/source/isaaclab/test/cli/test_misc_commands.py +++ b/source/isaaclab/test/cli/test_misc_commands.py @@ -9,6 +9,7 @@ import pytest +import isaaclab.cli as cli import isaaclab.cli.commands.misc as misc pytestmark = pytest.mark.unit @@ -28,14 +29,26 @@ def test_python_subcommands_propagate_failures(): ] -def test_build_docs_runs_sphinx_with_the_uv_dev_extra(): +def test_build_docs_runs_sphinx_with_the_uv_dev_extra(tmp_path, monkeypatch): """The docs command must use the UV extra that provides Sphinx.""" - docs_dir = misc.ISAACLAB_ROOT / "docs" + monkeypatch.setattr(misc, "ISAACLAB_ROOT", tmp_path) + docs_dir = tmp_path / "docs" output_dir = docs_dir / "_build" / "current" + output_dir.mkdir(parents=True) + (output_dir / "removed.html").touch() + (output_dir / ".doctrees").mkdir() + (output_dir / ".doctrees" / "environment.pickle").touch() + other_version = docs_dir / "_build" / "v3.0.0-EA" + other_version.mkdir() + (other_version / "index.html").touch() + + def check_clean_output(*args, **kwargs): + assert not output_dir.exists() + assert (other_version / "index.html").is_file() with ( mock.patch("shutil.which", return_value="/usr/bin/uv"), - mock.patch.object(misc, "run_command") as run_command, + mock.patch.object(misc, "run_command", side_effect=check_clean_output) as run_command, ): misc.command_build_docs() @@ -43,7 +56,6 @@ def test_build_docs_runs_sphinx_with_the_uv_dev_extra(): [ "/usr/bin/uv", "run", - "--isolated", "--extra", "dev", "--", @@ -57,7 +69,7 @@ def test_build_docs_runs_sphinx_with_the_uv_dev_extra(): "-b", "html", "-d", - "_build/doctrees", + str(output_dir / ".doctrees"), ".", str(output_dir), ], @@ -65,6 +77,59 @@ def test_build_docs_runs_sphinx_with_the_uv_dev_extra(): ) +def test_build_docs_multi_redirects_to_selected_ref(tmp_path, monkeypatch): + """The CLI must write its root redirect to the selected built version.""" + monkeypatch.setattr(misc, "ISAACLAB_ROOT", tmp_path) + monkeypatch.setenv("DOCS_DEFAULT_REF", "develop") + monkeypatch.setattr("sys.argv", ["isaaclab", "--docs_multi"]) + docs_dir = tmp_path / "docs" + output_dir = docs_dir / "_build" + (output_dir / "develop").mkdir(parents=True) + (output_dir / "develop" / "index.html").write_text("built docs", encoding="utf-8") + (docs_dir / "_redirect").mkdir() + (docs_dir / "_redirect" / "index.html").write_text( + '', encoding="utf-8" + ) + + with ( + mock.patch("shutil.which", return_value="/usr/bin/uv"), + mock.patch.object(misc, "run_command") as run_command, + ): + cli.cli() + + assert (output_dir / "index.html").read_text(encoding="utf-8") == ( + '' + ) + assert (output_dir / "develop" / "index.html").read_text(encoding="utf-8") == "built docs" + run_command.assert_called_once_with( + ["/usr/bin/uv", "run", "--extra", "dev", "--", "sphinx-multiversion", ".", str(output_dir), "--jobs=auto"], + cwd=docs_dir, + ) + + +def test_build_docs_multi_rejects_missing_default_ref(tmp_path, monkeypatch): + """An unbuilt redirect target must fail with guidance and leave any redirect intact.""" + monkeypatch.setattr(misc, "ISAACLAB_ROOT", tmp_path) + monkeypatch.delenv("DOCS_DEFAULT_REF", raising=False) + output_dir = tmp_path / "docs" / "_build" + output_dir.mkdir(parents=True) + redirect = output_dir / "index.html" + redirect.write_text("previous redirect", encoding="utf-8") + + with ( + mock.patch("shutil.which", return_value="/usr/bin/uv"), + mock.patch.object(misc, "run_command"), + mock.patch.object(misc, "print_error") as print_error, + pytest.raises(SystemExit, match="1"), + ): + misc.command_build_docs(multi_version=True) + + print_error.assert_called_once_with( + "Default docs ref 'v3.0.0-EA' was not built. Fetch the Git refs or set DOCS_DEFAULT_REF." + ) + assert redirect.read_text(encoding="utf-8") == "previous redirect" + + def test_build_docs_explains_how_to_install_uv(): """The docs command must fail with actionable guidance when UV is unavailable.""" with ( diff --git a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py index f06ef4d368dd..4193f3e90fc8 100644 --- a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py +++ b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py @@ -123,5 +123,28 @@ def test_body_offset_jacobian_uses_offset_in_root_frame(compute, returns_jacobia torch.testing.assert_close(result, expected) +@pytest.mark.parametrize(("ee_quat", "ik_offset"), [((0.0, 0.0, 0.0, 1.0), 1.0), ((0.0, 0.0, 0.0, 0.0), 0.0)]) +def test_apply_actions_holds_joints_for_uninitialized_frame(ee_quat, ik_offset): + """IK targets are applied for a valid frame pose and joints hold for all-zero quaternions.""" + device = "cpu" + joint_pos = torch.tensor([[0.1, 0.2], [0.3, 0.4]], device=device) + frame_pose = (torch.zeros(2, 3, device=device), torch.tensor([ee_quat] * 2, device=device)) + written = {} + stub = SimpleNamespace( + cfg=SimpleNamespace(controller=SimpleNamespace(joint_limit_avoidance_gain=0.0)), + _asset=SimpleNamespace( + data=SimpleNamespace(joint_pos=SimpleNamespace(torch=joint_pos)), + set_joint_position_target_index=lambda target, joint_ids: written.update(target=target), + ), + _joint_ids=slice(None), + _limits_injected=True, + _compute_frame_pose=lambda: frame_pose, + _compute_frame_jacobian=lambda: torch.zeros(2, 6, 2, device=device), + _ik_controller=SimpleNamespace(compute=lambda ee_pos, ee_quat, jacobian, joint_pos: joint_pos + 1.0), + ) + DifferentialInverseKinematicsAction.apply_actions(stub) + torch.testing.assert_close(written["target"], joint_pos + ik_offset) + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/source/isaaclab/test/envs/test_root_state_events.py b/source/isaaclab/test/envs/test_root_state_events.py new file mode 100644 index 000000000000..fc1f76b21239 --- /dev/null +++ b/source/isaaclab/test/envs/test_root_state_events.py @@ -0,0 +1,87 @@ +# 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 + +"""Unit tests for root-state event terms that sample from range dictionaries.""" + +from types import SimpleNamespace + +import pytest +import torch + +from isaaclab.envs.mdp import push_by_setting_velocity, reset_root_state_uniform +from isaaclab.managers import EventTermCfg, SceneEntityCfg + +pytestmark = pytest.mark.unit + + +class _Scene(dict): + """Scene double exposing assets by name and the environment origins.""" + + def __init__(self, asset, env_origins: torch.Tensor): + super().__init__(robot=asset) + self.env_origins = env_origins + + +def _make_env(device: str, num_envs: int = 3) -> tuple[SimpleNamespace, SimpleNamespace]: + """Create an environment double whose robot records the written root state.""" + default_root_pose = torch.zeros(num_envs, 7, device=device) + default_root_pose[:, 2] = 0.4 + default_root_pose[:, 6] = 1.0 + asset = SimpleNamespace( + device=device, + data=SimpleNamespace( + default_root_pose=SimpleNamespace(torch=default_root_pose), + default_root_vel=SimpleNamespace(torch=torch.zeros(num_envs, 6, device=device)), + root_vel_w=SimpleNamespace(torch=torch.zeros(num_envs, 6, device=device)), + ), + written={}, + ) + asset.write_root_pose_to_sim_index = lambda root_pose, env_ids: asset.written.update(pose=root_pose) + asset.write_root_velocity_to_sim_index = lambda root_velocity, env_ids: asset.written.update(vel=root_velocity) + env_origins = torch.arange(num_envs * 3, dtype=torch.float, device=device).reshape(num_envs, 3) + env = SimpleNamespace(num_envs=num_envs, device=device, scene=_Scene(asset, env_origins)) + return env, asset + + +def test_reset_root_state_uniform_uses_call_ranges(): + """The term samples from the ranges passed at call time, not the ones present at construction.""" + device = "cpu" + env, asset = _make_env(device) + asset_cfg = SceneEntityCfg("robot") + cfg = EventTermCfg( + func=reset_root_state_uniform, + mode="reset", + params={"pose_range": {}, "velocity_range": {}, "asset_cfg": asset_cfg}, + ) + term = reset_root_state_uniform(cfg, env) + env_ids = torch.arange(env.num_envs, device=device) + + term(env, env_ids, pose_range={"x": (1.0, 1.0), "z": (0.5, 0.5)}, velocity_range={"yaw": (2.0, 2.0)}) + + expected_pos = env.scene.env_origins + torch.tensor([1.0, 0.0, 0.9], device=device) + torch.testing.assert_close(asset.written["pose"][:, :3], expected_pos) + torch.testing.assert_close(asset.written["pose"][:, 3:], asset.data.default_root_pose.torch[:, 3:]) + expected_vel = torch.zeros(env.num_envs, 6, device=device) + expected_vel[:, 5] = 2.0 + torch.testing.assert_close(asset.written["vel"], expected_vel) + + +def test_push_by_setting_velocity_follows_range_edits(): + """Ranges are matched to components by name, and in-place range edits still take effect.""" + device = "cpu" + env, asset = _make_env(device) + env_ids = torch.arange(env.num_envs, device=device) + # Dictionary insertion order differs from the command's component order. + velocity_range = {"yaw": (-0.2, 0.8), "x": (-2.0, 1.0)} + push_by_setting_velocity(env, env_ids, velocity_range) + velocity = asset.written["vel"] + assert ((velocity[:, 0] >= -2.0) & (velocity[:, 0] <= 1.0)).all() + assert ((velocity[:, 5] >= -0.2) & (velocity[:, 5] <= 0.8)).all() + assert (velocity[:, 1:5] == 0.0).all() + + # a curriculum may edit the range dictionary in place + velocity_range["x"] = (-2.0, -2.0) + push_by_setting_velocity(env, env_ids, velocity_range) + torch.testing.assert_close(asset.written["vel"][:, 0], torch.full((env.num_envs,), -2.0, device=device)) diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 27b76d300f67..fe00e34d64fd 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -371,6 +371,45 @@ def scratch_term(env, value): torch.testing.assert_close(result[:, 3:], torch.full_like(env.data.pos_w, 2.0)) +def custom_noise(data: torch.Tensor, cfg: noise.NoiseCfg) -> torch.Tensor: + """Custom noise owns any storage it modifies; absolute mode returns an unchanged view.""" + return data.view_as(data) if cfg.operation == "abs" else data.clone().add_(0.2) + + +@pytest.mark.parametrize( + ("noise_cfg", "expected_delta"), + [ + (noise.UniformNoiseCfg(n_min=-0.1, n_max=0.1), None), + (noise.NoiseCfg(func=custom_noise), 0.2), + (noise.NoiseCfg(func=custom_noise, operation="abs"), 0.0), + (noise.NoiseModelCfg(noise_cfg=noise.NoiseCfg(func=custom_noise, operation="abs")), 0.0), + ], + ids=["out_of_place", "custom", "view", "model_view"], +) +def test_noise_preserves_source_and_rng_stream(setup_env, noise_cfg, expected_delta): + """Noise ownership paths preserve source storage, clipping, scaling, and random samples.""" + env = setup_env + cfg = ObservationGroupCfg(enable_corruption=True) + cfg.position = ObservationTermCfg(func=pos_w_data, noise=noise_cfg, clip=(0.0, 0.5), scale=2.0) + manager = ObservationManager({"policy": cfg}, env) + source = torch.clone(env.data.pos_w) + torch.manual_seed(0) + if expected_delta is None: + expected_delta = torch.rand_like(source) * 0.2 - 0.1 + expected = (source + expected_delta).clip(0.0, 0.5) * 2.0 + torch.manual_seed(0) + result = manager.compute()["policy"] + torch.testing.assert_close(env.data.pos_w, source) + torch.testing.assert_close(result, expected) + + # Without post-processing, the returned observation must still own its storage. + manager.cfg["policy"].position.clip = None + manager.cfg["policy"].position.scale = None + result = manager.compute()["policy"] + result.zero_() + torch.testing.assert_close(env.data.pos_w, source) + + def test_compute_with_2d_history(setup_env): env = setup_env """Test the observation computation with history buffers for 2D observations.""" diff --git a/source/isaaclab/test/managers/test_reward_manager.py b/source/isaaclab/test/managers/test_reward_manager.py index 56b1e1d1e7d4..e7d3f910521d 100644 --- a/source/isaaclab/test/managers/test_reward_manager.py +++ b/source/isaaclab/test/managers/test_reward_manager.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause from collections import namedtuple +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -90,20 +91,33 @@ class MyRewardManagerAnnotatedCfg: assert rew_man_from_dict._term_cfgs == rew_man_from_cfg._term_cfgs -def test_compute(env): - """Test the computation of reward.""" +def test_compute(): + """A partial reset reports each term's accumulated reward and preserves other environments.""" + num_envs, dt, max_episode_length_s = 3, 0.25, 2.0 + sim = MagicMock() + sim.is_playing.return_value = False + env = SimpleNamespace(num_envs=num_envs, device="cpu", sim=sim, max_episode_length_s=max_episode_length_s, step=0) cfg = { - "term_1": RewardTermCfg(func=grilled_chicken, weight=10), - "term_2": RewardTermCfg(func=grilled_chicken_with_curry, weight=0.0, params={"hot": False}), + "scaled": RewardTermCfg(func=env_index_scaled, weight=-2.0, params={"factor": 1.0}), + "scalar": RewardTermCfg(func=grilled_chicken, weight=4.0), + "disabled": RewardTermCfg(func=grilled_chicken_with_bbq, weight=0.0, params={"bbq": True}), } rew_man = RewardManager(cfg, env) - # compute expected reward - expected_reward = cfg["term_1"].weight * env.dt - # compute reward using manager - rewards = rew_man.compute(dt=env.dt) - # check the reward for environment index 0 - assert float(rewards[0]) == expected_reward - assert tuple(rewards.shape) == (env.num_envs,) + + for step, expected in ((1, [1.0, 0.5, 0.0]), (2, [1.0, 0.0, -1.0])): + env.step = step + torch.testing.assert_close(rew_man.compute(dt=dt), torch.tensor(expected)) + + reset_env_2 = rew_man.reset(torch.tensor([2])) + assert {name: value.item() for name, value in reset_env_2.items()} == { + "Episode_Reward/scaled": -1.5, + "Episode_Reward/scalar": 1.0, + "Episode_Reward/disabled": 0.0, + } + reset_env_1 = rew_man.reset(torch.tensor([1])) + assert reset_env_1["Episode_Reward/scaled"].item() == -0.75 + assert reset_env_1["Episode_Reward/scalar"].item() == 1.0 + assert all(value.item() == 0.0 for value in rew_man.reset(torch.tensor([2])).values()) def test_config_empty(env): @@ -162,3 +176,7 @@ def test_invalid_reward_config(env): } with pytest.raises(ValueError): RewardManager(cfg, env) + + +def env_index_scaled(env, factor: float): + return factor * env.step * torch.arange(env.num_envs, dtype=torch.float, device=env.device) diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index 7529e08595ae..d1721d0c3ac4 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -267,6 +267,7 @@ def test_first_visualize_defaults_to_first_prototype_when_count_matches_prototyp def test_usd_marker(sim): """Test with marker from a USD.""" + sim._has_offscreen_render = True # create a marker config = clone(FRAME_MARKER_CFG) config.prim_path = "/World/Visuals/test_frames" @@ -291,6 +292,15 @@ def test_visualization_skips_updates_when_invisible(sim): config = clone(POSITION_GOAL_MARKER_CFG) config.prim_path = "/World/Visuals/test_protos" test_marker = VisualizationMarkers(config) + initial_count = test_marker.count + # Unused inputs are ignored until a backend becomes active. + test_marker.visualize( + translations=torch.zeros(3, 3, device=sim.device), orientations=torch.zeros(2, 4, device=sim.device) + ) + assert test_marker.count == initial_count + sim._has_offscreen_render = True + test_marker.visualize(translations=torch.zeros(3, 3, device=sim.device)) + assert test_marker.count == 3 # play the simulation sim.reset() diff --git a/source/isaaclab/test/sensors/test_ray_caster_sensor.py b/source/isaaclab/test/sensors/test_ray_caster_sensor.py index 18c63863f311..2d973c705df9 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_sensor.py +++ b/source/isaaclab/test/sensors/test_ray_caster_sensor.py @@ -181,18 +181,21 @@ def test_ray_caster_reset_resamples_drift(sim_ground): sim_utils.create_prim("/World/Sensor", "Xform", translation=(0.0, 0.0, 2.0)) cfg = _ray_caster_cfg("/World/Sensor", "world") - cfg.drift_range = (0.01, 0.05) # force non-zero drift sensor = RayCaster(cfg) sim.reset() - # sim.reset() initializes the sensor with zero drift; call sensor.reset() to resample - # from the configured drift_range before we capture the baseline. + sensor.reset() + assert (sensor.drift.torch == 0.0).all() + assert (sensor.ray_cast_drift.torch == 0.0).all() + + sensor.cfg.drift_range = (0.01, 0.05) + sensor.cfg.ray_cast_drift_range = {"x": (0.1, 0.2), "y": (-0.2, -0.1)} sensor.reset() dt = 0.01 sensor.update(dt) drift_before = sensor.drift.clone() # (1, 3) torch tensor - lo, hi = cfg.drift_range + lo, hi = sensor.cfg.drift_range # After sensor.reset(), drift should be within the configured range assert drift_before.shape == (1, 3), f"Drift shape should be (1, 3), got {drift_before.shape}" @@ -216,6 +219,20 @@ def test_ray_caster_reset_resamples_drift(sim_ground): assert not torch.allclose(drift_after, drift_before), ( "reset() must resample drift; values must change from initial sample" ) + # Ray cast drift is sampled per axis; missing axes default to zero range. + ray_cast_drift = sensor.ray_cast_drift.torch + assert ((ray_cast_drift[:, 0] >= 0.1) & (ray_cast_drift[:, 0] <= 0.2)).all() + assert ((ray_cast_drift[:, 1] >= -0.2) & (ray_cast_drift[:, 1] <= -0.1)).all() + assert (ray_cast_drift[:, 2] == 0.0).all() + + # Disabling either drift clears its old sample. + sensor.cfg.drift_range = (0.0, 0.0) + sensor.reset(env_ids=[0]) + assert (sensor.drift.torch == 0.0).all() + assert (sensor.ray_cast_drift.torch[:, 0] >= 0.1).all() + sensor.cfg.ray_cast_drift_range = {} + sensor.reset(env_ids=[0]) + assert (sensor.ray_cast_drift.torch == 0.0).all() @pytest.mark.isaacsim_ci diff --git a/source/isaaclab/test/sensors/test_sensor_base.py b/source/isaaclab/test/sensors/test_sensor_base.py index cfcb70d33cfb..235569656fe6 100644 --- a/source/isaaclab/test/sensors/test_sensor_base.py +++ b/source/isaaclab/test/sensors/test_sensor_base.py @@ -81,6 +81,21 @@ class DummySensorCfg(SensorBaseCfg): prim_path = "{ENV_REGEX_NS}/Cube/dummy_sensor" +class DummyVisSensor(DummySensor): + """Dummy sensor whose debug visualization records the counts it displays.""" + + def __init__(self, cfg): + self.visualized_counts = [] + super().__init__(cfg) + + def _set_debug_vis_impl(self, debug_vis: bool): + pass + + def _debug_vis_callback(self, event): + if self._is_initialized: + self.visualized_counts.append(torch.clone(self.data.count)) + + def _populate_scene(): """""" @@ -171,7 +186,8 @@ def test_sensor_update_rate(create_dummy_sensor, device): """ sensor_cfg, sim, dt = create_dummy_sensor sensor_cfg.update_period = 2 * dt - sensor = DummySensor(cfg=sensor_cfg) + sensor_cfg.debug_vis = True + sensor = DummyVisSensor(cfg=sensor_cfg) # Play sim sim.step() @@ -191,6 +207,14 @@ def test_sensor_update_rate(create_dummy_sensor, device): ) expected_value += i % 2 + backend_update_count = sensor.backend_update_count + for _ in range(3): + sensor.update(dt=dt) + assert sensor.backend_update_count == backend_update_count + sim.vis_marker_registry.dispatch_callbacks() + assert sensor.backend_update_count == backend_update_count + 1 + torch.testing.assert_close(sensor.visualized_counts[-1], sensor.data.count) + @pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_sensor_reset(create_dummy_sensor, device): diff --git a/source/isaaclab/test/utils/test_circular_buffer.py b/source/isaaclab/test/utils/test_circular_buffer.py index 6563e8b89f4a..5b51f9fa5f9b 100644 --- a/source/isaaclab/test/utils/test_circular_buffer.py +++ b/source/isaaclab/test/utils/test_circular_buffer.py @@ -257,19 +257,18 @@ def test_stack_dim_positive_index_equivalent_to_negative(): torch.testing.assert_close(buf_neg.stacked, buf_pos.stacked) -def test_stack_dim_ring_shift_after_overflow(): - """After K+1 frames, the oldest slot must be frame 1 (frame 0 evicted), newest = last.""" - B, H, W, C, K = 2, 4, 4, 3, 2 +@pytest.mark.parametrize(("height", "width"), [(2, 3), (512, 256)], ids=["staged_shift", "large_frame_shift"]) +def test_stack_dim_ring_shift_after_overflow(height: int, width: int): + """After K+1 frames, the oldest frame is evicted and the slots hold frames 1..K in order.""" + B, C, K = 2, 3, 4 buf = CircularBuffer(max_len=K, batch_size=B, device="cpu", stack_dim=-1) - f0 = torch.full((B, H, W, C), 0.0) - f1 = torch.full((B, H, W, C), 1.0) - f2 = torch.full((B, H, W, C), 2.0) - buf.append(f0) - buf.append(f1) - buf.append(f2) - stacked = buf.stacked # K=2, so slots are [f1, f2] - torch.testing.assert_close(stacked[..., :C], torch.full((B, H, W, C), 1.0)) - torch.testing.assert_close(stacked[..., C:], torch.full((B, H, W, C), 2.0)) + for value in range(K + 1): + buf.append(torch.full((B, height, width, C), float(value))) + stacked = buf.stacked + for slot in range(K): + torch.testing.assert_close( + stacked[..., slot * C : (slot + 1) * C], torch.full((B, height, width, C), slot + 1.0) + ) def test_stack_dim_reset_clears_buffer(): diff --git a/source/isaaclab/test/utils/test_noise.py b/source/isaaclab/test/utils/test_noise.py index bbcac7872d81..5c8033a84156 100644 --- a/source/isaaclab/test/utils/test_noise.py +++ b/source/isaaclab/test/utils/test_noise.py @@ -31,9 +31,11 @@ def test_gaussian_noise(device, noise_device, op): # create noise config noise_cfg = noise.GaussianNoiseCfg(std=std, mean=mean, operation=op) + source = data.clone() for _ in range(2): # apply noise noisy_data = noise_cfg.func(data, cfg=noise_cfg) + torch.testing.assert_close(data, source) # calculate resulting noise compared to original data set if op == "add": std_result, mean_result = torch.std_mean(noisy_data - data, dim=0) @@ -61,9 +63,11 @@ def test_uniform_noise(device, noise_device, op): # create noise config noise_cfg = noise.UniformNoiseCfg(n_max=n_max, n_min=n_min, operation=op) + source = data.clone() for _ in range(2): # apply noise noisy_data = noise_cfg.func(data, cfg=noise_cfg) + torch.testing.assert_close(data, source) # calculate resulting noise compared to original data set if op == "add": min_result, _ = torch.min(noisy_data - data, dim=0) @@ -93,9 +97,11 @@ def test_constant_noise(device, noise_device, op): # create noise config noise_cfg = noise.ConstantNoiseCfg(bias=bias, operation=op) + source = data.clone() for _ in range(2): # apply noise noisy_data = noise_cfg.func(data, cfg=noise_cfg) + torch.testing.assert_close(data, source) # calculate resulting noise compared to original data set if op == "add": bias_result = noisy_data - data diff --git a/source/isaaclab_assets/changelog.d/towncrier-migration.skip b/source/isaaclab_assets/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index 713018324e3e..fba688dca036 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 1.0.0 (2026-09-20) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/changelog.d/perf-hot-path-7b-sensors.changed.rst b/source/isaaclab_contrib/changelog.d/perf-hot-path-7b-sensors.changed.rst new file mode 100644 index 000000000000..c44f5db3a09f --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/perf-hot-path-7b-sensors.changed.rst @@ -0,0 +1 @@ +* Changed visuo-tactile sensor debug visualization to refresh outdated sensor buffers before drawing. diff --git a/source/isaaclab_contrib/changelog.d/towncrier-migration.skip b/source/isaaclab_contrib/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_contrib/docs/CHANGELOG.rst b/source/isaaclab_contrib/docs/CHANGELOG.rst index 62165706d7b5..63f44a65e01d 100644 --- a/source/isaaclab_contrib/docs/CHANGELOG.rst +++ b/source/isaaclab_contrib/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 3.1.0 (2026-09-29) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py index 9fc1a3c768e0..8f86d7fa073e 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py +++ b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py @@ -905,6 +905,8 @@ def _debug_vis_callback(self, event): # Safety check - return if not properly initialized if not hasattr(self, "_tactile_visualizer") or self._tactile_visualizer is None: return + if self._is_initialized: + self._update_outdated_buffers() vis_points = None if self.cfg.visualize_sdf_closest_pts and hasattr(self, "debug_closest_points_wolrd"): diff --git a/source/isaaclab_experimental/changelog.d/towncrier-migration.skip b/source/isaaclab_experimental/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_experimental/docs/CHANGELOG.rst b/source/isaaclab_experimental/docs/CHANGELOG.rst index 75e86b9fd04e..3a7b9a476335 100644 --- a/source/isaaclab_experimental/docs/CHANGELOG.rst +++ b/source/isaaclab_experimental/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 0.2.4 (2026-09-30) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_mimic/changelog.d/towncrier-migration.skip b/source/isaaclab_mimic/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_mimic/docs/CHANGELOG.rst b/source/isaaclab_mimic/docs/CHANGELOG.rst index c643196552a4..7097226a9988 100644 --- a/source/isaaclab_mimic/docs/CHANGELOG.rst +++ b/source/isaaclab_mimic/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 2.0.10 (2026-09-28) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/perf-hot-path-7b-newton.changed.rst b/source/isaaclab_newton/changelog.d/perf-hot-path-7b-newton.changed.rst new file mode 100644 index 000000000000..7e36adc6ab51 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/perf-hot-path-7b-newton.changed.rst @@ -0,0 +1,5 @@ +* Removed the redundant per-step solver-internal reset from :meth:`~isaaclab_newton.physics.NewtonManager.step`. + The reset still runs through :meth:`~isaaclab_newton.physics.NewtonManager.forward` for worlds flagged by + state writes. +* Skipped the device-to-host readback in joint position-limit writes of + :class:`~isaaclab_newton.assets.Articulation` when the clamped-default message would not be logged. diff --git a/source/isaaclab_newton/changelog.d/perf-hot-path-7b-newton.fixed.rst b/source/isaaclab_newton/changelog.d/perf-hot-path-7b-newton.fixed.rst new file mode 100644 index 000000000000..f731b979cc3b --- /dev/null +++ b/source/isaaclab_newton/changelog.d/perf-hot-path-7b-newton.fixed.rst @@ -0,0 +1,4 @@ +* Fixed ``body_com_acc_w`` of :class:`~isaaclab_newton.assets.Articulation`, + :class:`~isaaclab_newton.assets.RigidObject`, and :class:`~isaaclab_newton.assets.RigidObjectCollection` + dividing by the physics time step when an update spans several physics steps, such as when Newton owns + the decimation loop. The finite difference now uses the elapsed time since the previous update. diff --git a/source/isaaclab_newton/changelog.d/perf-hot-path-7b-sensors.changed.rst b/source/isaaclab_newton/changelog.d/perf-hot-path-7b-sensors.changed.rst new file mode 100644 index 000000000000..4e0d2d27e192 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/perf-hot-path-7b-sensors.changed.rst @@ -0,0 +1,3 @@ +* Removed the unused per-frame output copy loop from + :class:`~isaaclab_newton.renderers.NewtonWarpRenderer`, whose outputs alias the camera buffers. +* Changed Newton contact and PVA debug visualization to refresh outdated sensor buffers before drawing. diff --git a/source/isaaclab_newton/changelog.d/towncrier-migration.skip b/source/isaaclab_newton/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 71b8e4156950..1db2725e44e9 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 9.1.0 (2026-09-29) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index e905d1adb0e7..93cf3044f6a5 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -1797,7 +1797,11 @@ def write_joint_position_limit_to_sim_index( """ env_ids = self._resolve_env_ids(env_ids) joint_ids = self._resolve_joint_ids(joint_ids) - clamped_defaults = wp.zeros(1, dtype=wp.int32, device=self.device) + # Count clamped defaults (a host sync) only when the resulting message would be logged. + log_level = logging.WARNING if warn_limit_violation else logging.INFO + report_clamping = logger.isEnabledFor(log_level) + if report_clamping: + self._clamped_default_count.zero_() if isinstance(limits, float): raise ValueError("Joint position limits must be a tensor or array, not a float.") self.assert_shape_and_dtype(limits, (env_ids.shape[0], joint_ids.shape[0]), wp.vec2f, "limits") @@ -1829,20 +1833,17 @@ def write_joint_position_limit_to_sim_index( self.data._sim_bind_joint_pos_limits_upper, self.data._soft_joint_pos_limits, self.data._default_joint_pos, - clamped_defaults, + self._clamped_default_count, ], device=self.device, ) self.data._joint_pos_limits.timestamp = self.data._sim_timestamp - if clamped_defaults.numpy()[0] > 0: - violation_message = ( + if report_clamping and self._clamped_default_count.numpy()[0] > 0: + logger.log( + log_level, "Some default joint positions are outside of the range of the new joint limits. Default joint positions" - " will be clamped to be within the new joint limits." + " will be clamped to be within the new joint limits.", ) - if warn_limit_violation: - logger.warning(violation_message) - else: - logger.info(violation_message) SimulationManager.add_model_change(ModelFlags.JOINT_DOF_PROPERTIES) def write_joint_position_limit_to_sim_mask( @@ -1871,7 +1872,11 @@ def write_joint_position_limit_to_sim_mask( """ env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) joint_mask = self._resolve_mask(joint_mask, self._ALL_JOINT_MASK) - clamped_defaults = wp.zeros(1, dtype=wp.int32, device=self.device) + # Count clamped defaults (a host sync) only when the resulting message would be logged. + log_level = logging.WARNING if warn_limit_violation else logging.INFO + report_clamping = logger.isEnabledFor(log_level) + if report_clamping: + self._clamped_default_count.zero_() if isinstance(limits, float): raise ValueError("Joint position limits must be a tensor or array, not a float.") self.assert_shape_and_dtype_mask(limits, (env_mask, joint_mask), wp.vec2f, "limits") @@ -1903,20 +1908,17 @@ def write_joint_position_limit_to_sim_mask( self.data._sim_bind_joint_pos_limits_upper, self.data._soft_joint_pos_limits, self.data._default_joint_pos, - clamped_defaults, + self._clamped_default_count, ], device=self.device, ) self.data._joint_pos_limits.timestamp = self.data._sim_timestamp - if clamped_defaults.numpy()[0] > 0: - violation_message = ( + if report_clamping and self._clamped_default_count.numpy()[0] > 0: + logger.log( + log_level, "Some default joint positions are outside of the range of the new joint limits. Default joint positions" - " will be clamped to be within the new joint limits." + " will be clamped to be within the new joint limits.", ) - if warn_limit_violation: - logger.warning(violation_message) - else: - logger.info(violation_message) SimulationManager.add_model_change(ModelFlags.JOINT_DOF_PROPERTIES) def write_joint_velocity_limit_to_sim_index( @@ -3451,6 +3453,8 @@ def _create_buffers(self): np.arange(self.num_spatial_tendons, dtype=np.int32), device=self.device ) self._ALL_SPATIAL_TENDON_MASK = wp.ones((self.num_spatial_tendons,), dtype=wp.bool, device=self.device) + # Scratch counter for default joint positions clamped by a joint position-limit write. + self._clamped_default_count = wp.zeros(1, dtype=wp.int32, device=self.device) # Lazily-filled cache of backend-order collision-shape counts (see ``backend_num_shapes_per_body``). self._num_shapes_per_body_backend: list[int] | None = None diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index 3591606f8592..a629e8c22ee2 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -819,6 +819,8 @@ def body_com_acc_w(self) -> ProxyArray: All values are relative to the world. """ if self._body_com_acc_w.timestamp < self._sim_timestamp: + # Finite-difference over the elapsed time, which spans the decimation when Newton owns it. + time_elapsed = self._sim_timestamp - self._body_com_acc_w.timestamp body_ordering = self.body_ordering wp.launch( articulation_kernels.get_body_com_acc_from_body_com_vel_ordered, @@ -829,7 +831,7 @@ def body_com_acc_w(self) -> ProxyArray: self._previous_body_com_vel, body_ordering.user_to_backend if body_ordering is not None else None, body_ordering is not None, - SimulationManager.get_dt(), + time_elapsed, ], outputs=[self._body_com_acc_w.data], ) diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py index ff8f88f9dda9..f76e2084b9f3 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py @@ -420,13 +420,15 @@ def body_com_acc_w(self) -> ProxyArray: This quantity is the acceleration of the rigid bodies' center of mass frame relative to the world. """ if self._body_com_acc_w.timestamp < self._sim_timestamp: + # Finite-difference over the elapsed time, which spans the decimation when Newton owns it. + time_elapsed = self._sim_timestamp - self._body_com_acc_w.timestamp wp.launch( shared_kernels.derive_body_acceleration_from_body_com_velocities, dim=(self._num_instances, 1), device=self.device, inputs=[ self._sim_bind_body_com_vel_w, - SimulationManager.get_dt(), + time_elapsed, self._previous_body_com_vel, ], outputs=[ diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection_data.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection_data.py index 1b997e2a0758..8b75235bed6a 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection_data.py @@ -354,13 +354,15 @@ def body_com_acc_w(self) -> ProxyArray: This quantity is the acceleration of the rigid bodies' center of mass frame relative to the world. """ if self._body_com_acc_w.timestamp < self._sim_timestamp: + # Finite-difference over the elapsed time, which spans the decimation when Newton owns it. + time_elapsed = self._sim_timestamp - self._body_com_acc_w.timestamp wp.launch( shared_kernels.derive_body_acceleration_from_body_com_velocities, dim=(self.num_instances, self.num_bodies), device=self.device, inputs=[ self.body_com_vel_w.warp, - SimulationManager.get_dt(), + time_elapsed, self._previous_body_com_vel, ], outputs=[ diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py index 1b0bd4a4f0b9..0a4a18ef795b 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py @@ -116,10 +116,10 @@ def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: With ``use_mujoco_cpu=True`` the solver owns a single global ``MjData`` and its reset path is not mask-aware — it clears the buffers for every - world. Since this hook fires on every step/forward boundary (usually - with an all-``False`` mask), the CPU path is gated on at least one - world actually being flagged so warm-starting is not defeated on every - step. + world. Since this hook can fire with an all-``False`` mask (e.g. on + every forward boundary after a graph-captured write), the CPU path is + gated on at least one world actually being flagged so warm-starting is + not defeated on every step. Args: world_mask: Per-world bool mask of shape ``(world_count + 1,)``. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index ef081c173a70..9642989db9e0 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -714,8 +714,6 @@ def step(cls) -> None: if sim is None or not sim.is_playing(): return - cls._reset_solver_internals_delegate(cls._world_reset_mask) - # Notify solver of model changes if cls._model_changes: with wp.ScopedDevice(PhysicsManager._device): @@ -727,6 +725,7 @@ def step(cls) -> None: NewtonManager._model_changes = set() # Reset-authored state and persistent solver resources must be ready before capture. + # forward() also resets solver internals for the worlds flagged since the last boundary. cls.forward() cfg = PhysicsManager._cfg device = PhysicsManager._device diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py index 5265417d3fca..849e4b99655c 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -656,17 +656,8 @@ def _launch_render(self, render_data: RenderData) -> None: render_data._convert_segmentation() def read_output(self, render_data: RenderData, camera_data: CameraData) -> None: - """Copy rendered outputs to the camera data buffers. + """Publish segmentation metadata; pixel outputs alias the camera buffers bound in :meth:`set_outputs`. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.read_output`.""" - for output_name in camera_data.output: - if output_name == "rgb": - continue - image_data = render_data.get_output(output_name) - if image_data is not None: - output_wp = camera_data.output[output_name].warp - if image_data.ptr != output_wp.ptr: - wp.copy(output_wp, image_data) - # Publish the segmentation id-to-label metadata (idToLabels / idToSemantics) alongside the # pixel buffers. for output_name, info in render_data.segmentation_info().items(): diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py index 4f7ff5d70246..307ffaff1bc7 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py @@ -560,6 +560,7 @@ def _set_debug_vis_impl(self, debug_vis: bool): def _debug_vis_callback(self, event): if not self._is_initialized: return + self._update_outdated_buffers() sensing_transforms = wp.to_torch(self.contact_view.sensing_obj_transforms) positions = sensing_transforms.reshape(self._num_envs, self._num_sensors, 7)[..., :3] diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py b/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py index 14894e2f1fa2..269d6bca9bf1 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py @@ -209,6 +209,7 @@ def _set_debug_vis_impl(self, debug_vis: bool): def _debug_vis_callback(self, event): if self._newton_model is None: return + self._update_outdated_buffers() # base position (offset upward for visibility) base_pos_w = self._data.pos_w.torch.clone() base_pos_w[:, 2] += 0.5 diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index f0f4b5da6dbc..2bb728645b39 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -22,10 +22,12 @@ launch_test_simulation() +import logging import sys from copy import copy, deepcopy from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch import newton import numpy as np @@ -1020,6 +1022,7 @@ def test_newton_ordered_state_caches_invalidate_on_rebind( articulation.update(sim.cfg.dt) data = articulation.data + previous_body_com_vel = data._previous_body_com_vel.numpy().copy() primed_joint_vel_values = ( np.arange(np.prod(data._sim_bind_joint_vel.shape), dtype=np.float32).reshape(data._sim_bind_joint_vel.shape) + 100.0 @@ -1040,13 +1043,24 @@ def test_newton_ordered_state_caches_invalidate_on_rebind( # ``joint_vel`` shadow) observes the primed backend state. No-op under identity # ordering, where the getters alias the sim-bound arrays directly. data._refresh_user_order_state() - data.update(sim.cfg.dt) + # One update spanning two physics steps, as when Newton owns the decimation loop. + update_dt = 2 * sim.cfg.dt + data.update(update_dt) primed_joint_acc = data.joint_acc.warp.numpy().copy() primed_body_com_acc_w = data.body_com_acc_w.warp.numpy().copy() assert data._joint_acc.timestamp == data._sim_timestamp assert data._body_com_acc_w.timestamp == data._sim_timestamp assert np.any(primed_joint_acc != 0.0) - assert np.any(primed_body_com_acc_w != 0.0) + primed_body_user_to_backend = ( + np.asarray(articulation.body_ordering.user_to_backend_indices) + if articulation.body_ordering is not None + else np.arange(articulation.num_bodies) + ) + np.testing.assert_allclose( + primed_body_com_acc_w, + ((primed_body_com_vel_values - previous_body_com_vel) / update_dt)[:, primed_body_user_to_backend], + rtol=1e-5, + ) public_to_binding = { "joint_pos": "_sim_bind_joint_pos", @@ -2394,14 +2408,15 @@ def test_setting_articulation_root_prim_path(sim, device, articulation_type, roo @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) @pytest.mark.parametrize("articulation_type", ["anymal"]) -def test_write_joint_state_data_consistency(sim, num_articulations, device, gravity_enabled, articulation_type): +def test_write_joint_state_data_consistency(sim, num_articulations, device, gravity_enabled, articulation_type, caplog): """Joint limit and joint state writes update the joint buffers and refresh the body state without a step. This test verifies that: 1. Joint position limits are written and keep in-limit default joint positions 2. A partial joint state write with unsorted int64 selectors updates only the selected entries 3. A joint state write moves the bodies and refreshes the derived body poses and velocities - 4. Indexed joint limits that exclude a default joint position clamp it into the new limits + 4. Joint limits that exclude a default joint position clamp it into the new limits and report the + clamping at the requested log level Args: sim: The simulation fixture @@ -2491,7 +2506,10 @@ def test_write_joint_state_data_consistency(sim, num_articulations, device, grav limits = torch.zeros(env_ids.shape[0], joint_ids.shape[0], 2, device=device) limits[..., 0] = torch.rand(env_ids.shape[0], joint_ids.shape[0], device=device) * -0.1 limits[..., 1] = torch.rand(env_ids.shape[0], joint_ids.shape[0], device=device) * 0.1 - articulation.write_joint_position_limit_to_sim_index(limits=limits, env_ids=env_ids, joint_ids=joint_ids) + articulation_logger = Articulation.__module__ + with caplog.at_level(logging.WARNING, logger=articulation_logger): + articulation.write_joint_position_limit_to_sim_index(limits=limits, env_ids=env_ids, joint_ids=joint_ids) + assert any(record.levelno == logging.WARNING for record in caplog.records) # Check new limits are in place and the defaults are clamped into them torch.testing.assert_close(articulation.data.joint_pos_limits.torch[env_ids][:, joint_ids], limits) @@ -2501,6 +2519,21 @@ def test_write_joint_state_data_consistency(sim, num_articulations, device, grav ) assert torch.all(within_bounds) + # Info-level clamping reports are logged only when that level is enabled. + articulation.data.default_joint_pos.torch.fill_(1.0) + full_limits = torch.zeros(num_articulations, articulation.num_joints, 2, device=device) + full_limits[..., 1] = 0.5 + caplog.clear() + with caplog.at_level(logging.INFO, logger=articulation_logger): + articulation.write_joint_position_limit_to_sim_mask(limits=full_limits, warn_limit_violation=False) + assert [record.levelno for record in caplog.records] == [logging.INFO] + articulation.data.default_joint_pos.torch.fill_(1.0) + caplog.clear() + with caplog.at_level(logging.WARNING, logger=articulation_logger): + articulation.write_joint_position_limit_to_sim_mask(limits=full_limits, warn_limit_violation=False) + assert not caplog.records + torch.testing.assert_close(articulation.data.default_joint_pos.torch, torch.full_like(full_limits[..., 1], 0.5)) + @pytest.mark.parametrize("selector_kind", ["index", "mask"]) @pytest.mark.parametrize("num_articulations", [2]) @@ -2577,7 +2610,6 @@ def test_body_q_consistent_after_root_write(num_articulations, device, articulat active, then patches ``_simulate_physics_only`` to capture body_q at the moment collide() is called and asserts it matches joint_q. """ - from unittest.mock import patch sim_cfg = SimulationCfg( dt=1 / 200, diff --git a/source/isaaclab_newton/test/assets/test_rigid_object.py b/source/isaaclab_newton/test/assets/test_rigid_object.py index 39a756a60b12..a49149b30455 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object.py @@ -440,6 +440,12 @@ def test_gravity_vec_w_tracks_model_gravity(num_cubes, device): gravity[:, :, 2] = -9.81 torch.testing.assert_close(cube_object.data.body_acc_w.torch, gravity) + # One update may span several physics steps, e.g. once per env step when Newton owns decimation. + sim.step() + sim.step() + cube_object.update(2 * sim.cfg.dt) + torch.testing.assert_close(cube_object.data.body_acc_w.torch, gravity) + # GRAVITY_VEC_W must share storage with Newton's per-env gravity array. model = SimulationManager.get_model() model_gravity_arr = model.gravity[: model.world_count] diff --git a/source/isaaclab_newton/test/assets/test_rigid_object_collection.py b/source/isaaclab_newton/test/assets/test_rigid_object_collection.py index 8724ad8ac51d..563266cab4fa 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object_collection.py @@ -403,6 +403,12 @@ def test_gravity_vec_w_tracks_model_gravity(num_envs, num_cubes, device): gravity[..., 2] = -9.81 torch.testing.assert_close(object_collection.data.body_com_acc_w.torch, gravity) + # One update may span several physics steps, e.g. once per env step when Newton owns decimation. + sim.step() + sim.step() + object_collection.update(2 * sim.cfg.dt) + torch.testing.assert_close(object_collection.data.body_com_acc_w.torch, gravity) + # GRAVITY_VEC_W must share storage with Newton's per-env gravity array. model = SimulationManager.get_model() model_gravity_arr = model.gravity[: model.world_count] diff --git a/source/isaaclab_ov/changelog.d/perf-hot-path-7b-sensors.changed.rst b/source/isaaclab_ov/changelog.d/perf-hot-path-7b-sensors.changed.rst new file mode 100644 index 000000000000..cd90193693a1 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/perf-hot-path-7b-sensors.changed.rst @@ -0,0 +1,7 @@ +* Changed OVPhysX PVA and frame-transformer debug visualization to refresh outdated sensor + buffers before drawing. +* Changed the OVPhysX rigid-object ``body_com_acc_w`` finite difference to use the elapsed time since the + previous update, matching joint accelerations and the Newton backend. + +* Skipped the joint-limit clamping counter readback when its logging level is disabled and reused + the counter buffer across writes. diff --git a/source/isaaclab_ov/changelog.d/towncrier-migration.skip b/source/isaaclab_ov/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index 25effdd55fbc..16a96338ebf0 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 5.1.0 (2026-09-30) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py index b1137fe2ff93..8bc71c184231 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py @@ -1518,7 +1518,10 @@ def write_joint_position_limit_to_sim_index( device=self._device, ) # Clamp default_joint_pos to the new limits and refresh soft_joint_pos_limits. - clamped_count = wp.zeros(1, dtype=wp.int32, device=self._device) + log_level = logging.WARNING if warn_limit_violation else logging.INFO + report_clamping = logger.isEnabledFor(log_level) + if report_clamping: + self._clamped_default_count.zero_() wp.launch( clamp_default_joint_pos_and_update_soft_limits_index_kernel(env_ids, joint_ids), dim=(env_ids.shape[0], joint_ids.shape[0]), @@ -1531,19 +1534,16 @@ def write_joint_position_limit_to_sim_index( outputs=[ self._data._default_joint_pos, self._data._soft_joint_pos_limits, - clamped_count, + self._clamped_default_count, ], device=self._device, ) - if clamped_count.numpy()[0] > 0: + if report_clamping and self._clamped_default_count.numpy()[0] > 0: violation_message = ( "Some default joint positions are outside of the range of the new joint limits. Default joint" " positions will be clamped to be within the new joint limits." ) - if warn_limit_violation: - logger.warning(violation_message) - else: - logger.info(violation_message) + logger.log(log_level, violation_message) # Stage to pinned-host CPU: flatten the vec2f buffer to float32 view. self._push_joint_property( TT.DOF_LIMIT, @@ -1613,7 +1613,10 @@ def write_joint_position_limit_to_sim_mask( device=self._device, ) # Clamp default_joint_pos to the new limits and refresh soft_joint_pos_limits. - clamped_count = wp.zeros(1, dtype=wp.int32, device=self._device) + log_level = logging.WARNING if warn_limit_violation else logging.INFO + report_clamping = logger.isEnabledFor(log_level) + if report_clamping: + self._clamped_default_count.zero_() wp.launch( clamp_default_joint_pos_and_update_soft_limits_mask, dim=(self._num_instances, self._num_joints), @@ -1626,19 +1629,16 @@ def write_joint_position_limit_to_sim_mask( outputs=[ self._data._default_joint_pos, self._data._soft_joint_pos_limits, - clamped_count, + self._clamped_default_count, ], device=self._device, ) - if clamped_count.numpy()[0] > 0: + if report_clamping and self._clamped_default_count.numpy()[0] > 0: violation_message = ( "Some default joint positions are outside of the range of the new joint limits. Default joint" " positions will be clamped to be within the new joint limits." ) - if warn_limit_violation: - logger.warning(violation_message) - else: - logger.info(violation_message) + logger.log(log_level, violation_message) self._push_joint_property( TT.DOF_LIMIT, self._data._joint_pos_limits.data, @@ -4081,6 +4081,7 @@ def _create_buffers(self) -> None: self._ALL_INDICES = wp.array(np.arange(N, dtype=np.int32), device=device) self._ALL_BODY_INDICES = wp.array(np.arange(B, dtype=np.int32), device=device) self._ALL_JOINT_INDICES = wp.array(np.arange(J, dtype=np.int32), device=device) + self._clamped_default_count = wp.zeros(1, dtype=wp.int32, device=self._device) self._ALL_FIXED_TENDON_INDICES = wp.array(np.arange(FT, dtype=np.int32), device=device) self._ALL_SPATIAL_TENDON_INDICES = wp.array(np.arange(ST, dtype=np.int32), device=device) self._sim_env_ids = wp.empty(N, dtype=wp.int32, device=device) diff --git a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object_data.py b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object_data.py index f1eb37fd1a80..feaca89b506e 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object_data.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object_data.py @@ -452,13 +452,15 @@ def body_com_acc_w(self) -> ProxyArray: if self._body_com_acc_w.timestamp < self._sim_timestamp: if self._previous_body_com_vel is None: self._previous_body_com_vel = wp.clone(self.body_com_vel_w.warp) + # Finite-difference over the elapsed time, matching joint accelerations. + time_elapsed = self._sim_timestamp - self._body_com_acc_w.timestamp wp.launch( shared_kernels.derive_body_acceleration_from_body_com_velocities, dim=(self._num_instances, 1), device=self.device, inputs=[ self.body_com_vel_w.warp, - SimulationManager.get_physics_dt(), + time_elapsed, self._previous_body_com_vel, ], outputs=[ diff --git a/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py b/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py index 0dec1c3c1368..3c9d0c633135 100644 --- a/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py +++ b/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py @@ -477,6 +477,7 @@ def _set_debug_vis_impl(self, debug_vis: bool) -> None: def _debug_vis_callback(self, event) -> None: if not self.is_initialized or self._raw_transforms is None or not hasattr(self, "frame_visualizer"): return + self._update_outdated_buffers() # Convert warp -> torch at the boundary for visualization source_pos_w = wp.to_torch(self._data._source_pos_w) diff --git a/source/isaaclab_ov/isaaclab_ov/sensors/pva/pva.py b/source/isaaclab_ov/isaaclab_ov/sensors/pva/pva.py index 233866f61749..a29d15c4d9ac 100644 --- a/source/isaaclab_ov/isaaclab_ov/sensors/pva/pva.py +++ b/source/isaaclab_ov/isaaclab_ov/sensors/pva/pva.py @@ -272,6 +272,7 @@ def _debug_vis_callback(self, event): # safely return if the sensor has not been bound yet (matches the PhysX `_view is None` idiom) if self._root_view is None: return + self._update_outdated_buffers() # get marker location # -- base state (convert warp -> torch for visualization) base_pos_w = self._data.pos_w.torch.clone() diff --git a/source/isaaclab_ov/test/assets/test_articulation.py b/source/isaaclab_ov/test/assets/test_articulation.py index 40cea80c376a..f95e7aa082a4 100644 --- a/source/isaaclab_ov/test/assets/test_articulation.py +++ b/source/isaaclab_ov/test/assets/test_articulation.py @@ -51,6 +51,7 @@ from __future__ import annotations import importlib +import logging import sys from pathlib import Path from types import SimpleNamespace @@ -1763,7 +1764,7 @@ def test_out_of_range_default_joint_vel(sim, device): @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) -def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): +def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane, caplog): """Test write_joint_limits_to_sim API and when default pos falls outside of the new limits. This test verifies that: @@ -1834,6 +1835,22 @@ def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): ) assert torch.all(within_bounds) + # Logging level changes must not change clamping or reuse an old violation count. + limits = torch.zeros(num_articulations, articulation.num_joints, 2, device=device) + limits[..., 1] = 0.5 + articulation_logger = type(articulation).__module__ + for level in (logging.WARNING, logging.INFO): + articulation.data.default_joint_pos.torch.fill_(1.0) + caplog.clear() + with caplog.at_level(level, logger=articulation_logger): + articulation.write_joint_position_limit_to_sim_index(limits=limits, warn_limit_violation=False) + assert bool(caplog.records) == (level == logging.INFO) + torch.testing.assert_close(articulation.data.default_joint_pos.torch, torch.full_like(limits[..., 1], 0.5)) + caplog.clear() + with caplog.at_level(logging.INFO, logger=articulation_logger): + articulation.write_joint_position_limit_to_sim_mask(limits=limits, warn_limit_violation=False) + assert not caplog.records + @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) diff --git a/source/isaaclab_physx/changelog.d/perf-hot-path-7b-sensors.changed.rst b/source/isaaclab_physx/changelog.d/perf-hot-path-7b-sensors.changed.rst new file mode 100644 index 000000000000..53a24fbf8d3d --- /dev/null +++ b/source/isaaclab_physx/changelog.d/perf-hot-path-7b-sensors.changed.rst @@ -0,0 +1,7 @@ +* Removed the per-frame channel-compacting copies for normals, motion vectors, HDR color, and + simple-shading outputs in :class:`~isaaclab_physx.renderers.IsaacRtxRenderer`. +* Changed PhysX contact, PVA, and frame-transformer debug visualization to refresh outdated + sensor buffers before drawing. + +* Skipped the joint-limit clamping counter readback when its logging level is disabled and reused + the counter buffer across writes. diff --git a/source/isaaclab_physx/changelog.d/towncrier-migration.skip b/source/isaaclab_physx/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 9596c3d9bd7b..c509abcd5da6 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 7.3.0 (2026-09-29) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index 27c87503269f..c023134d3a95 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -1603,7 +1603,10 @@ def write_joint_position_limit_to_sim_index( if env_ids.shape[0] == 0 or joint_ids.shape[0] == 0: return - clamped_defaults = wp.zeros(1, dtype=wp.int32, device=self.device) + log_level = logging.WARNING if warn_limit_violation else logging.INFO + report_clamping = logger.isEnabledFor(log_level) + if report_clamping: + self._clamped_default_count.zero_() sim_env_ids = self._sim_env_ids_view(env_ids.shape[0]) # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. # Note: we are doing a single launch for faster performance. Prior versions would do this in multiple launches. @@ -1623,21 +1626,18 @@ def write_joint_position_limit_to_sim_index( self.data._joint_pos_limits, self.data._soft_joint_pos_limits, self.data._default_joint_pos, - clamped_defaults, + self._clamped_default_count, sim_env_ids, ], device=self.device, ) # Log a warning if the default joint positions are outside of the new limits. - if clamped_defaults.numpy()[0] > 0: + if report_clamping and self._clamped_default_count.numpy()[0] > 0: violation_message = ( "Some default joint positions are outside of the range of the new joint limits. Default joint positions" " will be clamped to be within the new joint limits." ) - if warn_limit_violation: - logger.warning(violation_message) - else: - logger.info(violation_message) + logger.log(log_level, violation_message) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids, sim_env_ids) joint_pos_limits_backend = self._get_backend_ordered_joint_buffer( @@ -3971,6 +3971,7 @@ def _resolve_joint_dof_signs(self) -> tuple[int, ...]: def _create_buffers(self): self._ALL_INDICES = wp.array(np.arange(self.num_instances, dtype=np.int32), device=self.device) self._ALL_JOINT_INDICES = wp.array(np.arange(self.num_joints, dtype=np.int32), device=self.device) + self._clamped_default_count = wp.zeros(1, dtype=wp.int32, device=self.device) self._ALL_BODY_INDICES = wp.array(np.arange(self.num_bodies, dtype=np.int32), device=self.device) self._ALL_FIXED_TENDON_INDICES = wp.array(np.arange(self.num_fixed_tendons, dtype=np.int32), device=self.device) self._ALL_SPATIAL_TENDON_INDICES = wp.array( diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index df6b40130987..c49d3f51d3ca 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -695,19 +695,14 @@ def tiling_grid_shape(): ptr=tiled_data_buffer.ptr, shape=(*tiled_data_buffer.shape, 4), dtype=wp.uint8, device=device ) - # For motion vectors, use specialized kernel that reads 4 channels but only writes 2 - # Note: Not doing this breaks the alignment of the data (check: https://github.com/isaac-sim/IsaacLab/issues/2003) - if data_type == "motion_vectors": - tiled_data_buffer = tiled_data_buffer[:, :, :2].contiguous() - - # For normals, we only require the first three channels of the tiled buffer - # Note: Not doing this breaks the alignment of the data (check: https://github.com/isaac-sim/IsaacLab/issues/4239) - if data_type == "normals": - tiled_data_buffer = tiled_data_buffer[:, :, :3].contiguous() - if data_type in SIMPLE_SHADING_MODES: - tiled_data_buffer = tiled_data_buffer[:, :, :3].contiguous() - if data_type == str(RenderBufferKind.RGB_HDR): - tiled_data_buffer = tiled_data_buffer[:, :, :3].contiguous() + tile_height, tile_width, num_channels = (int(dim) for dim in buf_wp.shape[1:]) + # Motion vectors, normals, HDR color, and simple shading annotators return 4 channels while the + # outputs keep only the leading ones. Index the source with its own channel count so the + # kernel, which copies only the destination channels, stays aligned without a compacting copy + # (see https://github.com/isaac-sim/IsaacLab/issues/2003 and #4239). + source_channels = num_channels + if data_type in ("motion_vectors", "normals", str(RenderBufferKind.RGB_HDR), *SIMPLE_SHADING_MODES): + source_channels = int(tiled_data_buffer.shape[2]) # ``reshape_tiled_image`` indexes the tiled buffer as # (num_tiles_y * height, num_tiles_x * width, channels), but annotators hand this data back @@ -718,12 +713,11 @@ def tiling_grid_shape(): # are ignored exactly as the previous flattened indexing ignored them. Keeping the view 3D # instead of 1D also keeps every dimension within Warp's per-dimension array size limit, so # large environment counts and camera resolutions no longer overflow a flattened dimension. - tile_height, tile_width, num_channels = (int(dim) for dim in buf_wp.shape[1:]) # ``tiled_source`` must outlive the view below: the view does not own the annotator memory. tiled_source = tiled_data_buffer tiled_data_buffer = wp.array( ptr=tiled_source.ptr, - shape=(num_tiles_y * tile_height, num_tiles_x * tile_width, num_channels), + shape=(num_tiles_y * tile_height, num_tiles_x * tile_width, source_channels), dtype=tiled_source.dtype, device=device, ) diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py index 1aeb71a2daeb..083db94eaf65 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py @@ -701,6 +701,7 @@ def _debug_vis_callback(self, event): # note: this invalidity happens because of isaac sim view callbacks if self.body_physx_view is None: return + self._update_outdated_buffers() # Convert warp data to torch at the boundary for visualization net_forces_torch = self._data.net_normal_forces_w.torch # (N, B, 3) net_contact_force_w = torch.linalg.norm(net_forces_torch, dim=-1) diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py b/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py index 9a9055e10c98..0401ab53868d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py @@ -486,6 +486,7 @@ def _set_debug_vis_impl(self, debug_vis: bool): self.frame_visualizer.set_visibility(False) def _debug_vis_callback(self, event): + self._update_outdated_buffers() # Convert warp -> torch at the boundary for visualization source_pos_w = wp.to_torch(self._data._source_pos_w) source_quat_w = wp.to_torch(self._data._source_quat_w) diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py b/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py index 7b52717ce34c..8e51737eea27 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py @@ -333,6 +333,7 @@ def _debug_vis_callback(self, event): # note: this invalidity happens because of isaac sim view callbacks if self._view is None: return + self._update_outdated_buffers() # get marker location # -- base state (convert warp -> torch for visualization) base_pos_w = self._data.pos_w.torch.clone() diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py index ca74b3562dfe..2aae7d9b2cf6 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py @@ -198,8 +198,6 @@ def _initialize_impl(self) -> None: def _get_view_transforms_wp(self) -> wp.array: """Refresh the PhysX transform buffer and return its cached typed Warp view.""" if self._transforms_prefetched: - if self._raw_transforms is None: - raise RuntimeError("RayCaster transforms were marked prefetched before a buffer was cached.") return self._raw_transforms if self._physx_body_view is None: diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 7078c528911e..e0a81ef3aa97 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -22,6 +22,7 @@ launch_test_simulation() +import logging import sys from pathlib import Path @@ -879,7 +880,7 @@ def test_out_of_range_default_joint_vel(sim, device): @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) -def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): +def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane, caplog): """Test write_joint_limits_to_sim API and when default pos falls outside of the new limits. This test verifies that: @@ -950,6 +951,22 @@ def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): ) assert torch.all(within_bounds) + # Logging level changes must not change clamping or reuse an old violation count. + limits = torch.zeros(num_articulations, articulation.num_joints, 2, device=device) + limits[..., 1] = 0.5 + articulation_logger = type(articulation).__module__ + for level in (logging.WARNING, logging.INFO): + articulation.data.default_joint_pos.torch.fill_(1.0) + caplog.clear() + with caplog.at_level(level, logger=articulation_logger): + articulation.write_joint_position_limit_to_sim_index(limits=limits, warn_limit_violation=False) + assert bool(caplog.records) == (level == logging.INFO) + torch.testing.assert_close(articulation.data.default_joint_pos.torch, torch.full_like(limits[..., 1], 0.5)) + caplog.clear() + with caplog.at_level(logging.INFO, logger=articulation_logger): + articulation.write_joint_position_limit_to_sim_mask(limits=limits, warn_limit_violation=False) + assert not caplog.records + @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 319ce055b8cb..0a75262bc7f3 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -486,6 +486,46 @@ def test_render_treats_empty_annotator_frame_as_not_ready(monkeypatch, data_type launch.assert_not_called() +@pytest.mark.parametrize( + ("data_type", "num_channels"), + [("normals", 3), ("motion_vectors", 2)], +) +def test_render_keeps_leading_channels_of_padded_annotator_tiles(monkeypatch, data_type, num_channels): + """Four-channel annotator tiles are split per camera, keeping only the output's leading channels.""" + _install_omni_stubs(monkeypatch) + import isaaclab_physx.renderers.isaac_rtx_renderer as rtx_renderer + from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererCfg + + # Four 2x3 cameras tiled on a 2x2 grid; every source value is distinct. + view_count, height, width = 4, 2, 3 + tiled = np.arange(2 * height * 2 * width * 4, dtype=np.float32).reshape(2 * height, 2 * width, 4) + annotator = MagicMock() + annotator.get_data.return_value = tiled + output_buffer = wp.zeros((view_count, height, width, num_channels), dtype=wp.float32, device="cpu") + render_data = SimpleNamespace( + annotators={data_type: annotator}, + output_data={data_type: SimpleNamespace(warp=output_buffer)}, + spec=SimpleNamespace(view_count=view_count, device="cpu", cfg=SimpleNamespace(width=width, height=height)), + renderer_info={}, + ppisp_pipeline=None, + _hdr_scratch_wp=None, + ) + renderer = rtx_renderer.IsaacRtxRenderer.__new__(rtx_renderer.IsaacRtxRenderer) + renderer.cfg = IsaacRtxRendererCfg() + + with patch.object(rtx_renderer, "ensure_isaac_rtx_render_update"): + renderer.render(render_data) + + expected = np.stack( + [ + tiled[row * height : (row + 1) * height, col * width : (col + 1) * width, :num_channels] + for row in range(2) + for col in range(2) + ] + ) + np.testing.assert_array_equal(output_buffer.numpy(), expected) + + @pytest.mark.parametrize( "states", [ diff --git a/source/isaaclab_ppisp/changelog.d/towncrier-migration.skip b/source/isaaclab_ppisp/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ppisp/docs/CHANGELOG.rst b/source/isaaclab_ppisp/docs/CHANGELOG.rst index 9efb639c4857..d3b4c7fe4621 100644 --- a/source/isaaclab_ppisp/docs/CHANGELOG.rst +++ b/source/isaaclab_ppisp/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 0.3.1 (2026-07-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/changelog.d/perf-hot-path-7b-tasks.changed.rst b/source/isaaclab_rl/changelog.d/perf-hot-path-7b-tasks.changed.rst new file mode 100644 index 000000000000..b582021d92bb --- /dev/null +++ b/source/isaaclab_rl/changelog.d/perf-hot-path-7b-tasks.changed.rst @@ -0,0 +1,2 @@ +* Computed the Stable-Baselines3 wrapper's done flags from the host copies of the termination and + truncation flags, saving one device-to-host transfer per step. diff --git a/source/isaaclab_rl/changelog.d/towncrier-migration.skip b/source/isaaclab_rl/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/docs/CHANGELOG.rst b/source/isaaclab_rl/docs/CHANGELOG.rst index ed947000cd38..8a4fdc18bff3 100644 --- a/source/isaaclab_rl/docs/CHANGELOG.rst +++ b/source/isaaclab_rl/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 1.3.1 (2026-09-29) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py b/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py index 5acbe9f571c2..924b1317f6b7 100644 --- a/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py +++ b/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py @@ -310,7 +310,6 @@ def step(self, actions): # noqa: D102 # process observations and states obs_and_states = self._process_obs(obs_dict) # move buffers to rl-device - # note: we perform clone to prevent issues when rl-device and sim-device are the same. rew = rew.to(device=self._rl_device) dones = (terminated | truncated).to(device=self._rl_device) extras = { diff --git a/source/isaaclab_rl/isaaclab_rl/sb3.py b/source/isaaclab_rl/isaaclab_rl/sb3.py index 6348db5c0ceb..b8403920d92e 100644 --- a/source/isaaclab_rl/isaaclab_rl/sb3.py +++ b/source/isaaclab_rl/isaaclab_rl/sb3.py @@ -245,8 +245,6 @@ def step_async(self, actions): # noqa: D102 def step_wait(self) -> VecEnvStepReturn: # noqa: D102 # record step information obs_dict, rew, terminated, truncated, extras = self.env.step(self._async_actions) - # compute reset ids - dones = terminated | truncated # convert data types to numpy depending on backend # note: ManagerBasedRLEnv uses torch backend (by default). @@ -254,7 +252,7 @@ def step_wait(self) -> VecEnvStepReturn: # noqa: D102 rewards = rew.detach().cpu().numpy() terminated = terminated.detach().cpu().numpy() truncated = truncated.detach().cpu().numpy() - dones = dones.detach().cpu().numpy() + dones = terminated | truncated reset_ids = dones.nonzero()[0] diff --git a/source/isaaclab_tasks/changelog.d/fix-pr8105-handover-tendons.fixed.rst b/source/isaaclab_tasks/changelog.d/fix-pr8105-handover-tendons.fixed.rst new file mode 100644 index 000000000000..94885b85034b --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-pr8105-handover-tendons.fixed.rst @@ -0,0 +1 @@ +* Fixed direct handover action application with device-resident tendon indices. diff --git a/source/isaaclab_tasks/changelog.d/perf-hot-path-7b-tasks.changed.rst b/source/isaaclab_tasks/changelog.d/perf-hot-path-7b-tasks.changed.rst new file mode 100644 index 000000000000..eccd19f06ea4 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/perf-hot-path-7b-tasks.changed.rst @@ -0,0 +1,24 @@ +* Stored the joint, body, and tendon indices of the Reorient, Cabinet, and locomotion direct tasks as + device tensors, and precomputed their static joint limits and default arm pose, removing host-to-device + index uploads and CUDA synchronizations from every physics substep. The public + ``actuated_dof_indices``, ``finger_bodies``, ``finger_wrench_bodies``, ``arm_joint_ids``, and + ``finger_joint_ids`` attributes, and the indices returned by + :func:`~isaaclab_tasks.core.reorient.utils.resolve_actuated_tendons`, are now ``torch.long`` tensors + instead of lists. Call ``.tolist()`` where a list is needed. +* Switched the Reorient, Cabinet, and locomotion direct tasks from the deprecated articulation target + setters to ``articulation.actuators.target_command``. +* Logged the Cabinet, locomotion direct, Lift deformable and cable, and Fourbar pole success metrics as + 0-d device tensors instead of synchronizing with ``.item()`` on every reset. Call ``.item()`` where a + float is needed. +* Changed :meth:`~isaaclab_tasks.core.reorient.utils.EpisodeErrorRecorder.reset` to compute its statistics + without a host synchronization. Once any error has been recorded, it returns NaN statistics instead of an + empty dictionary when none of the selected environments has a sample. Check the values with + :func:`torch.isnan` instead of testing for missing keys. +* Stacked the Lift contact sensor forces so :func:`~isaaclab_tasks.core.lift.mdp.contacts` and + :func:`~isaaclab_tasks.core.lift.mdp.contact_count` take one norm per call instead of one per sensor. +* Precomputed the ANYmal symmetry augmentation as one cached column permutation and sign tensor per + transform, removing the per-minibatch index and sign uploads (about 1.5 ms to 0.15 ms per call). +* Cached the locomotion walk-target offset and sampled Lift reset offsets through the shared + :func:`~isaaclab.utils.math.sample_uniform_from_ranges` helper, + and read the Reorient goal directly + from the command buffers instead of concatenating the command on every access. diff --git a/source/isaaclab_tasks/changelog.d/perf-hot-path-7b-tasks.minor b/source/isaaclab_tasks/changelog.d/perf-hot-path-7b-tasks.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/changelog.d/towncrier-migration.skip b/source/isaaclab_tasks/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index ed2ca3dfbe23..6dfc6bbd94a3 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 21.0.0 (2026-09-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_direct_env.py index f760674092d8..37ed3f5e781d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_direct_env.py @@ -29,8 +29,10 @@ def __init__(self, cfg: CabinetDirectEnvCfg, render_mode: str | None = None, **k super().__init__(cfg, render_mode, **kwargs) self._robot, self._cabinet = self.scene["robot"], self.scene["cabinet"] - self.arm_joint_ids, _ = self._robot.find_joints(self.cfg.arm_joint_names) - self.finger_joint_ids, _ = self._robot.find_joints(self.cfg.finger_joint_names) + arm_joint_ids, _ = self._robot.find_joints(self.cfg.arm_joint_names) + finger_joint_ids, _ = self._robot.find_joints(self.cfg.finger_joint_names) + self.arm_joint_ids = torch.tensor(arm_joint_ids, dtype=torch.long, device=self.device) + self.finger_joint_ids = torch.tensor(finger_joint_ids, dtype=torch.long, device=self.device) self.ee_body_idx = self._robot.find_bodies(self.cfg.ee_body_name)[0][0] self.left_finger_body_idx = self._robot.find_bodies(self.cfg.left_finger_body_name)[0][0] self.right_finger_body_idx = self._robot.find_bodies(self.cfg.right_finger_body_name)[0][0] @@ -52,6 +54,8 @@ def __init__(self, cfg: CabinetDirectEnvCfg, render_mode: str | None = None, **k self.previous_actions = torch.zeros_like(self.actions) self.arm_joint_targets = torch.zeros((self.num_envs, len(self.arm_joint_ids)), device=self.device) + # the default arm pose is static, so its gather is hoisted out of the per-step action path + self._arm_default_joint_pos = self._robot.data.default_joint_pos.torch[:, self.arm_joint_ids] self.finger_joint_targets = torch.zeros((self.num_envs, len(self.finger_joint_ids)), device=self.device) # frame offsets, repeated for every environment @@ -89,8 +93,7 @@ def _pre_physics_step(self, actions: torch.Tensor) -> None: self.actions[:] = actions self.arm_joint_targets[:] = ( - self._robot.data.default_joint_pos.torch[:, self.arm_joint_ids] - + self.cfg.arm_action_scale * self.actions[:, : len(self.arm_joint_ids)] + self._arm_default_joint_pos + self.cfg.arm_action_scale * self.actions[:, : len(self.arm_joint_ids)] ) self.finger_joint_targets[:] = torch.where( self.actions[:, -1:] < 0.0, @@ -99,14 +102,9 @@ def _pre_physics_step(self, actions: torch.Tensor) -> None: ) def _apply_action(self) -> None: - self._robot.set_joint_position_target_index( - target=self.arm_joint_targets, - joint_ids=self.arm_joint_ids, - ) - self._robot.set_joint_position_target_index( - target=self.finger_joint_targets, - joint_ids=self.finger_joint_ids, - ) + target_command = self._robot.actuators.target_command + target_command.set_position_index(value=self.arm_joint_targets, joint_ids=self.arm_joint_ids) + target_command.set_position_index(value=self.finger_joint_targets, joint_ids=self.finger_joint_ids) def _get_observations(self) -> dict[str, torch.Tensor]: robot_joint_pos = self._robot.data.joint_pos.torch - self._robot.data.default_joint_pos.torch @@ -211,8 +209,8 @@ def _reset_idx(self, env_ids: Sequence[int] | None) -> None: env_ids = torch.arange(self.num_envs, device=self.device) log = self.extras.setdefault("log", {}) - log["Metrics/success_rate"] = self._episode_succeeded[env_ids].float().mean().item() - log["Metrics/drawer_pos"] = self._best_drawer_pos[env_ids].mean().item() + log["Metrics/success_rate"] = self._episode_succeeded[env_ids].float().mean() + log["Metrics/drawer_pos"] = self._best_drawer_pos[env_ids].mean() for name, episode_sum in self._episode_reward_sums.items(): log[f"Episode_Reward/{name}"] = torch.mean(episode_sum[env_ids]) / self.max_episode_length_s index_fill_(episode_sum, env_ids, 0.0) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/mdp/rewards.py index a52913fdc7c0..6cb144bb4f5c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/mdp/rewards.py @@ -155,8 +155,8 @@ def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): def reset(self, env_ids: torch.Tensor): log = self._env.extras.setdefault("log", {}) - log["Metrics/success_rate"] = self.succeeded[env_ids].float().mean().item() - log["Metrics/drawer_pos"] = self.best_drawer_pos[env_ids].mean().item() + log["Metrics/success_rate"] = self.succeeded[env_ids].float().mean() + log["Metrics/drawer_pos"] = self.best_drawer_pos[env_ids].mean() index_fill_(self.succeeded, env_ids, False) index_fill_(self.best_drawer_pos, env_ids, 0.0) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py index 12f793d8f049..092fa0e7f74a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py @@ -35,7 +35,7 @@ def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): self._hold_steps = max(1, round(hold_time_s / env.step_dt)) def reset(self, env_ids: torch.Tensor): - self._env.extras.setdefault("log", {})["Metrics/success_rate"] = self._success[env_ids].mean().item() + self._env.extras.setdefault("log", {})["Metrics/success_rate"] = self._success[env_ids].mean() index_fill_(self._consecutive_upright, env_ids, 0.0) def __call__( diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py index 54434498e466..3b0eb2965f83 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py @@ -70,7 +70,7 @@ def __init__(self, cfg: HandoverEnvCfg, render_mode: str | None = None, **kwargs # Motors that pull a tendon rather than drive a joint. Both hands are the same model, so # one index set serves both. - self.actuated_tendon_indices: list[int] = [] + self.actuated_tendon_indices = torch.empty(0, dtype=torch.long, device=self.device) if cfg.actuated_tendon_names: self.actuated_tendon_indices, self.tendon_lower_limits, self.tendon_upper_limits = resolve_actuated_tendons( self.right_hand, @@ -146,7 +146,7 @@ def _apply_hand_action( prev_targets[:, idx] = targets hand.set_joint_position_target_index(target=targets, joint_ids=idx) - if self.actuated_tendon_indices: + if len(self.actuated_tendon_indices) > 0: # No moving average on the tendon target: the manager task's action term applies none, # and the two task variants have to stay comparable. # saturate like the joint target above: a Gaussian policy samples past the action range, diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py index c2a39d721d16..2949b509ce94 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py @@ -18,7 +18,7 @@ from isaaclab import cloner from isaaclab.managers import EventTermCfg, ManagerTermBase, ManagerTermBaseCfg, SceneEntityCfg from isaaclab.utils import instantiate -from isaaclab.utils.math import quat_apply, random_orientation, sample_uniform +from isaaclab.utils.math import quat_apply, random_orientation, sample_uniform, sample_uniform_from_ranges from isaaclab_tasks.utils.success_monitor import SuccessMonitor, SuccessMonitorCfg @@ -82,8 +82,7 @@ def reset_cable_state_uniform( asset: CableObject = env.scene[asset_cfg.name] segment_pose = asset.data.default_segment_pose_w.torch[env_ids].clone() segment_velocity = asset.data.default_segment_velocity_w.torch[env_ids].clone() - ranges = torch.tensor([position_range.get(axis, (0.0, 0.0)) for axis in ("x", "y", "z")], device=asset.device) - offset = sample_uniform(ranges[:, 0], ranges[:, 1], (segment_pose.shape[0], 3), device=asset.device) + offset = sample_uniform_from_ranges(position_range, ("x", "y", "z"), segment_pose.shape[0], device=asset.device) segment_pose[..., :3] += offset.unsqueeze(1) asset.write_segment_pose_to_sim_index(segment_pose=segment_pose, env_ids=env_ids) asset.write_segment_velocity_to_sim_index(segment_velocity=segment_velocity, env_ids=env_ids) @@ -822,9 +821,8 @@ def reset_deformable_over_support( deformable: DeformableObject = env.scene[asset_cfg.name] supports: tuple[RigidObject, RigidObject] = (env.scene[support_cfg[0].name], env.scene[support_cfg[1].name]) - ranges = torch.tensor([position_range.get(key, (0.0, 0.0)) for key in ("x", "y", "z")], device=deformable.device) num_envs = len(range(env.num_envs)[env_ids]) if isinstance(env_ids, slice) else len(env_ids) - offset = sample_uniform(ranges[:, 0], ranges[:, 1], (num_envs, 3), device=deformable.device) + offset = sample_uniform_from_ranges(position_range, ("x", "y", "z"), num_envs, device=deformable.device) nodal_state = deformable.data.default_nodal_state_w.torch[env_ids].clone() nodal_state[..., :3] += offset.unsqueeze(1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/observations.py index 315514474d44..95f02aed5cfc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/observations.py @@ -53,17 +53,8 @@ class body_state_b(ManagerTermBase): The state for each body is stacked horizontally as ``[position(3), quaternion(4)(xyzw), linvel(3), angvel(3)]`` and then concatenated over bodies. - - The body indices are baked to a device tensor at construction. """ - def __init__(self, cfg: ObservationTermCfg, env: ManagerBasedRLEnv): - super().__init__(cfg, env) - body_ids = cfg.params["body_asset_cfg"].body_ids - if isinstance(body_ids, list): - body_ids = torch.tensor(body_ids, dtype=torch.long, device=env.device) - self._body_ids = body_ids - def __call__( self, env: ManagerBasedRLEnv, @@ -86,10 +77,10 @@ def __call__( body_asset: Articulation = env.scene[body_asset_cfg.name] base_asset: Articulation = env.scene[base_asset_cfg.name] # world pose of the bodies, flattened over environments - body_pos_w = body_asset.data.body_pos_w.torch[:, self._body_ids] + body_pos_w = body_asset.data.body_pos_w.torch[:, body_asset_cfg.body_ids] num_bodies = body_pos_w.shape[1] body_pos_w = body_pos_w.reshape(-1, 3) - body_quat_w = body_asset.data.body_quat_w.torch[:, self._body_ids].reshape(-1, 4) + body_quat_w = body_asset.data.body_quat_w.torch[:, body_asset_cfg.body_ids].reshape(-1, 4) # world pose of the base frame, broadcast over the bodies root_pos_w = base_asset.data.root_link_pos_w.torch.unsqueeze(1).expand(-1, num_bodies, -1).reshape(-1, 3) root_quat_w = base_asset.data.root_link_quat_w.torch.unsqueeze(1).expand(-1, num_bodies, -1).reshape(-1, 4) @@ -99,8 +90,8 @@ def __call__( # and contact-response differences); pose-only states with observation history transfer across # physics backends, velocity states do not if include_vel: - body_lin_vel_w = body_asset.data.body_lin_vel_w.torch[:, self._body_ids].view(-1, 3) - body_ang_vel_w = body_asset.data.body_ang_vel_w.torch[:, self._body_ids].view(-1, 3) + body_lin_vel_w = body_asset.data.body_lin_vel_w.torch[:, body_asset_cfg.body_ids].view(-1, 3) + body_ang_vel_w = body_asset.data.body_ang_vel_w.torch[:, body_asset_cfg.body_ids].view(-1, 3) body_lin_vel_b = quat_apply_inverse(root_quat_w, body_lin_vel_w) body_ang_vel_b = quat_apply_inverse(root_quat_w, body_ang_vel_w) out = torch.cat((body_pos_b, body_quat_b, body_lin_vel_b, body_ang_vel_b), dim=1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/rewards.py index eb30aeb47be4..c0d7376b02fa 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/rewards.py @@ -67,14 +67,8 @@ def contacts(env: ManagerBasedRLEnv, threshold: float, thumb_name: str, finger_n Returns: Boolean tensor indicating good contact condition per environment. """ - thumb_mag = _contact_force_mag(env.scene.sensors[thumb_name], env.num_envs) - - any_finger_contact = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) - for finger_name in finger_names: - finger_mag = _contact_force_mag(env.scene.sensors[finger_name], env.num_envs) - any_finger_contact = any_finger_contact | (finger_mag > threshold) - - return (thumb_mag > threshold) & any_finger_contact + in_contact = _contact_force_mag(env, [thumb_name, *finger_names]) > threshold + return in_contact[:, 0] & in_contact[:, 1:].any(dim=1) def contact_count(env: ManagerBasedRLEnv, threshold: float, sensor_names: list[str]) -> torch.Tensor: @@ -91,11 +85,7 @@ def contact_count(env: ManagerBasedRLEnv, threshold: float, sensor_names: list[s Returns: Tensor of shape (num_envs,) with the count of sensors in contact per environment. """ - count = torch.zeros(env.num_envs, dtype=torch.float32, device=env.device) - - for sensor_name in sensor_names: - mag = _contact_force_mag(env.scene.sensors[sensor_name], env.num_envs) - count += (mag > threshold).float() + count = (_contact_force_mag(env, sensor_names) > threshold).float().sum(dim=1) return count / len(sensor_names) @@ -413,7 +403,7 @@ def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): def reset(self, env_ids: Sequence[int] | None = None) -> None: if env_ids is None: env_ids = slice(None) - self._env.extras.setdefault("log", {})["Metrics/success_rate"] = self._succeeded[env_ids].float().mean().item() + self._env.extras.setdefault("log", {})["Metrics/success_rate"] = self._succeeded[env_ids].float().mean() index_fill_(self._succeeded, env_ids, False) @@ -513,10 +503,21 @@ def cable_segment_goal_reached( return (distance < success_threshold).float() -def _contact_force_mag(sensor: ContactSensor, num_envs: int) -> torch.Tensor: - """Per-environment contact force magnitude [N] of a single-body, single-filter contact sensor.""" - force = sensor.data.normal_force_matrix_w.torch.view(num_envs, 3) - return torch.linalg.norm(force, dim=-1) +def _contact_force_mag(env: ManagerBasedRLEnv, sensor_names: Sequence[str]) -> torch.Tensor: + """Per-environment contact force magnitudes [N] of single-body, single-filter contact sensors. + + The forces are stacked so a single norm covers every sensor. + + Args: + env: The environment instance. + sensor_names: Names of the contact sensors in the scene. + + Returns: + Force magnitudes [N], shape ``(num_envs, len(sensor_names))``. + """ + sensors: list[ContactSensor] = [env.scene.sensors[name] for name in sensor_names] + forces = torch.stack([sensor.data.normal_force_matrix_w.torch.view(env.num_envs, 3) for sensor in sensors], dim=1) + return torch.linalg.norm(forces, dim=-1) def _deformable_com_goal_metrics( diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/terminations.py index 5f38e5d6ea9a..1eb5f0cd8cf7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/terminations.py @@ -153,7 +153,7 @@ def joint_vel_out_of_sim_limit( ) -> torch.Tensor: """Terminate when joint velocities exceed solver limits [m/s or rad/s, depending on joint type].""" asset: Articulation = env.scene[asset_cfg.name] - joint_ids = asset_cfg.joint_ids if asset_cfg.joint_ids is not None else slice(None) + joint_ids = asset_cfg.joint_ids return torch.any( torch.abs(asset.data.joint_vel.torch[:, joint_ids]) > asset.data.joint_vel_limits.torch[:, joint_ids], dim=1 ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py index e8efc3052826..7002d8fdf798 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py @@ -56,7 +56,8 @@ def __init__(self, cfg: AntEnvCfg | HumanoidEnvCfg, render_mode: str | None = No self.gear_ratio_scaled = self.joint_gears / torch.max(self.joint_gears) # resolve against the sensor's own body list: its ordering is backend-specific and does not # necessarily match the articulation's body ordering - self._feet_body_idx, _ = self.joint_wrench.find_bodies(self.cfg.feet_body_names) + feet_body_idx, _ = self.joint_wrench.find_bodies(self.cfg.feet_body_names) + self._feet_body_idx = torch.tensor(feet_body_idx, dtype=torch.long, device=self.device) # walk target, placed far enough away that the robot never reaches it self.targets = self.scene.env_origins + torch.tensor( @@ -69,7 +70,7 @@ def _pre_physics_step(self, actions: torch.Tensor) -> None: self.actions = actions.clone() # the action is clamped before scaling: unbounded joint efforts drive the solver to NaN forces = self.action_scale * self.joint_gears * torch.clamp(self.actions, -1.0, 1.0) - self.robot.set_joint_effort_target_index(target=forces) + self.robot.actuators.target_command.set_effort_index(value=forces) def _apply_action(self) -> None: # Joint effort targets persist in the articulation command buffer across decimation substeps. @@ -168,7 +169,7 @@ def _reset_idx(self, env_ids: Sequence[int] | None): # log survival success rate before resetting (survived = timed out without falling) survived = self.reset_time_outs[env_ids].float() - self.extras.setdefault("log", {})["Metrics/success_rate"] = survived.mean().item() + self.extras.setdefault("log", {})["Metrics/success_rate"] = survived.mean() super()._reset_idx(env_ids) index_fill_(self.actions, env_ids, 0.0) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/mdp/observations.py index 60580dd2636b..5b9ae367a461 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/mdp/observations.py @@ -7,6 +7,7 @@ from __future__ import annotations +import functools from typing import TYPE_CHECKING import torch @@ -45,7 +46,17 @@ def walk_target_w(env: ManagerBasedEnv, target_pos: tuple[float, float, float]) The target is specified relative to the environment origin so that every robot walks along the same direction in its own frame, independently of where its environment sits in the grid. """ - return env.scene.env_origins + torch.tensor(target_pos, device=env.device) + return env.scene.env_origins + _walk_target_offset(tuple(target_pos), str(env.device)) + + +@functools.cache +def _walk_target_offset(target_pos: tuple[float, float, float], device: str) -> torch.Tensor: + """Walk target offset [m] as a device tensor. + + Cached per ``(target_pos, device)`` so the hot path does not upload the constant on every call; + ``device`` is a string so that it is hashable for the cache. + """ + return torch.tensor(target_pos, device=device) def base_heading_proj( diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/rewards.py index 05f244726fd1..b03ad147b48d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/rewards.py @@ -39,7 +39,7 @@ def success_bonus( command_term: ReorientCommand = env.command_manager.get_term(command_name) # obtain the goal orientation - goal_quat_w = command_term.command[:, 3:7] + goal_quat_w = command_term.quat_command_w # obtain the threshold for the orientation error threshold = command_term.cfg.orientation_success_threshold # calculate the orientation error @@ -65,7 +65,7 @@ def track_pos_l2( command_term: ReorientCommand = env.command_manager.get_term(command_name) # obtain the goal position - goal_pos_e = command_term.command[:, 0:3] + goal_pos_e = command_term.pos_command_e # obtain the object position in the environment frame object_pos_e = asset.data.root_pos_w.torch - env.scene.env_origins @@ -93,7 +93,7 @@ def track_orientation_inv_l2( command_term: ReorientCommand = env.command_manager.get_term(command_name) # obtain the goal orientation - goal_quat_w = command_term.command[:, 3:7] + goal_quat_w = command_term.quat_command_w # calculate the orientation error dtheta = math_utils.quat_error_magnitude(asset.data.root_quat_w.torch, goal_quat_w) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/terminations.py index f3eec876a1e9..cceb9705186d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/mdp/terminations.py @@ -42,6 +42,6 @@ def object_away_from_goal( # object pos asset_pos_e = asset.data.root_pos_w.torch - env.scene.env_origins - goal_pos_e = command_term.command[:, :3] + goal_pos_e = command_term.pos_command_e return torch.linalg.norm(asset_pos_e - goal_pos_e, ord=2, dim=1) > threshold diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py index 417de502387f..20a84724c8b4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py @@ -45,26 +45,35 @@ def __init__(self, cfg: AllegroHandEnvCfg | ShadowHandEnvCfg, render_mode: str | # -- robot introspection: joints, bodies, limits -- self.num_hand_dofs = self.hand.num_joints - self.actuated_dof_indices, _ = self.hand.find_joints(cfg.actuated_joint_names) - if len(self.actuated_dof_indices) != len(cfg.actuated_joint_names): + actuated_dof_indices, _ = self.hand.find_joints(cfg.actuated_joint_names) + if len(actuated_dof_indices) != len(cfg.actuated_joint_names): raise ValueError( - f"Expected {len(cfg.actuated_joint_names)} actuated joints, found {len(self.actuated_dof_indices)}." + f"Expected {len(cfg.actuated_joint_names)} actuated joints, found {len(actuated_dof_indices)}." ) + self.actuated_dof_indices = torch.tensor(actuated_dof_indices, dtype=torch.long, device=self.device) + # a slice avoids the gather/scatter when every joint is actuated (``find_joints`` returns sorted ids) + all_actuated = actuated_dof_indices == list(range(self.num_hand_dofs)) + self._actuated_dof_ids = slice(None) if all_actuated else self.actuated_dof_indices - self.finger_bodies, fingertip_body_names = self.hand.find_bodies(self.cfg.fingertip_body_names) - if len(self.finger_bodies) != len(self.cfg.fingertip_body_names): + finger_bodies, fingertip_body_names = self.hand.find_bodies(self.cfg.fingertip_body_names) + if len(finger_bodies) != len(self.cfg.fingertip_body_names): raise ValueError( - f"Expected {len(self.cfg.fingertip_body_names)} fingertip bodies, found {len(self.finger_bodies)}." + f"Expected {len(self.cfg.fingertip_body_names)} fingertip bodies, found {len(finger_bodies)}." ) - self.num_fingertips = len(self.finger_bodies) + self.finger_bodies = torch.tensor(finger_bodies, dtype=torch.long, device=self.device) + self.num_fingertips = len(finger_bodies) self.finger_wrench_bodies = [] if self._joint_wrench_sensor is not None: - for body_name in fingertip_body_names: - self.finger_wrench_bodies.append(self._joint_wrench_sensor.body_names.index(body_name)) - self.finger_wrench_bodies.sort() + finger_wrench_bodies = sorted( + self._joint_wrench_sensor.body_names.index(name) for name in fingertip_body_names + ) + self.finger_wrench_bodies = torch.tensor(finger_wrench_bodies, dtype=torch.long, device=self.device) joint_pos_limits = self.hand.data.joint_limits.torch.to(self.device) self.hand_dof_lower_limits = joint_pos_limits[..., 0] self.hand_dof_upper_limits = joint_pos_limits[..., 1] + # the joint limits are static, so the actuated subset is gathered once rather than every substep + self._actuated_lower_limits = self.hand_dof_lower_limits[:, self._actuated_dof_ids] + self._actuated_upper_limits = self.hand_dof_upper_limits[:, self._actuated_dof_ids] # -- actuation targets (EMA-smoothed joint position targets) -- self.prev_targets = torch.zeros((self.num_envs, self.num_hand_dofs), dtype=torch.float, device=self.device) @@ -94,7 +103,7 @@ def __init__(self, cfg: AllegroHandEnvCfg | ShadowHandEnvCfg, render_mode: str | self.z_unit_tensor = torch.tensor([0, 0, 1], dtype=torch.float, device=self.device).repeat((self.num_envs, 1)) # -- articulation write handles -- - self._set_joint_pos_target = self.hand.set_joint_position_target_index + self._set_joint_pos_target = self.hand.actuators.target_command.set_position_index self._write_obj_root_pose = self.object.write_root_pose_to_sim_index self._write_obj_root_vel = self.object.write_root_velocity_to_sim_index self._write_hand_joint_pos = self.hand.write_joint_position_to_sim_index @@ -106,27 +115,19 @@ def _pre_physics_step(self, actions: torch.Tensor) -> None: def _apply_action(self) -> None: # Joint actions come first, matching the manager task's action-term order. A hand whose # motors also pull tendons consumes the remaining columns in its own subclass. + ids = self._actuated_dof_ids num_joint_actions = len(self.actuated_dof_indices) - self.cur_targets[:, self.actuated_dof_indices] = unscale_transform( - self.actions[:, :num_joint_actions], - self.hand_dof_lower_limits[:, self.actuated_dof_indices], - self.hand_dof_upper_limits[:, self.actuated_dof_indices], - ) - self.cur_targets[:, self.actuated_dof_indices] = ( - self.cfg.act_moving_average * self.cur_targets[:, self.actuated_dof_indices] - + (1.0 - self.cfg.act_moving_average) * self.prev_targets[:, self.actuated_dof_indices] + targets = unscale_transform( + self.actions[:, :num_joint_actions], self._actuated_lower_limits, self._actuated_upper_limits ) - self.cur_targets[:, self.actuated_dof_indices] = saturate( - self.cur_targets[:, self.actuated_dof_indices], - self.hand_dof_lower_limits[:, self.actuated_dof_indices], - self.hand_dof_upper_limits[:, self.actuated_dof_indices], + targets = ( + self.cfg.act_moving_average * targets + (1.0 - self.cfg.act_moving_average) * self.prev_targets[:, ids] ) + targets = saturate(targets, self._actuated_lower_limits, self._actuated_upper_limits) + self.cur_targets[:, ids] = targets + self.prev_targets[:, ids] = targets - self.prev_targets[:, self.actuated_dof_indices] = self.cur_targets[:, self.actuated_dof_indices] - - self._set_joint_pos_target( - target=self.cur_targets[:, self.actuated_dof_indices], joint_ids=self.actuated_dof_indices - ) + self._set_joint_pos_target(value=targets, joint_ids=ids) def _get_observations(self) -> dict: if self.cfg.asymmetric_obs: @@ -232,7 +233,6 @@ def _reset_idx(self, env_ids: Sequence[int]): # Per-attempt success rate, matching the manager workflow's metric of the same # name: reaching a goal draws a replacement, so exactly one goal is outstanding # when the episode ends and it presented ``successes + 1``. - # 0-dim device tensor: avoids a host sync here; consumers read it at logging cadence goals = self.successes[env_ids] self.extras.setdefault("log", {})["Metrics/success_rate"] = (goals / (goals + 1.0)).mean() for statistic, value in self._orientation_error.reset(env_ids).items(): @@ -244,8 +244,8 @@ def _reset_idx(self, env_ids: Sequence[int]): self._reset_target_pose(env_ids) # reset object - object_default_pose = self.object.data.default_root_pose.torch.clone()[env_ids] - object_default_vel = self.object.data.default_root_vel.torch.clone()[env_ids] + object_default_pose = self.object.data.default_root_pose.torch[env_ids] + object_default_vel = self.object.data.default_root_vel.torch[env_ids] pos_noise = sample_uniform(-1.0, 1.0, (len(env_ids), 3), device=self.device) # global object positions object_default_pose[:, 0:3] = ( @@ -272,7 +272,7 @@ def _reset_idx(self, env_ids: Sequence[int]): self.prev_targets[env_ids] = dof_pos self.cur_targets[env_ids] = dof_pos - self._set_joint_pos_target(target=dof_pos, env_ids=env_ids) + self._set_joint_pos_target(value=dof_pos, env_ids=env_ids) self._write_hand_joint_pos(position=dof_pos, env_ids=env_ids) self._write_hand_joint_vel(velocity=dof_vel, env_ids=env_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/utils.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/utils.py index 2678b7ea265e..dfb9b06a621a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/utils.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/utils.py @@ -41,6 +41,7 @@ def __init__(self, num_envs: int, device: str | torch.device): """ self.minimum_error = torch.full((num_envs,), torch.inf, device=device) self._has_sample = torch.zeros(num_envs, dtype=torch.bool, device=device) + self._updated = False def update(self, error: torch.Tensor) -> None: """Record one error sample for every environment. @@ -58,6 +59,7 @@ def update(self, error: torch.Tensor) -> None: # force a host synchronization every step, so substitute-and-minimum instead torch.minimum(self.minimum_error, torch.where(finite, error, self.minimum_error), out=self.minimum_error) self._has_sample |= finite + self._updated = True def reset(self, env_ids: Sequence[int] | torch.Tensor | slice | None = None) -> dict[str, torch.Tensor]: """Summarize and clear completed episodes. @@ -68,19 +70,20 @@ def reset(self, env_ids: Sequence[int] | torch.Tensor | slice | None = None) -> Returns: Mean, median, and 90th-percentile episode-minimum errors as 0-dim device tensors, so logging them does not force a host - synchronization in the reset path. The result is empty when none of - the selected environments has a sample. + synchronization in the reset path. Environments without a sample + are excluded. The result is empty before the first :meth:`update`, + and its values are NaN when none of the selected environments has + a sample. """ if env_ids is None: env_ids = slice(None) - valid = self._has_sample[env_ids] - values = self.minimum_error[env_ids][valid] statistics = {} - if values.numel() > 0: + if self._updated: + values = torch.where(self._has_sample[env_ids], self.minimum_error[env_ids], torch.nan) statistics = { - "mean": values.mean(), - "median": values.median(), - "p90": torch.quantile(values, 0.9), + "mean": values.nanmean(), + "median": values.nanmedian(), + "p90": torch.nanquantile(values, 0.9), } index_fill_(self.minimum_error, env_ids, torch.inf) index_fill_(self._has_sample, env_ids, False) @@ -255,7 +258,7 @@ def resolve_actuated_tendons( num_envs: int, device: str, position_limits: tuple[float, float], -) -> tuple[list[int], torch.Tensor, torch.Tensor]: +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Resolve a hand's actuated tendons and the limits its command is unscaled against. A tendon has its own index space, so the indices come from :meth:`find_fixed_tendons` rather @@ -271,7 +274,8 @@ def resolve_actuated_tendons( position_limits: Lower and upper bound of the tendon command [m or rad]. Returns: - The tendon indices and the lower / upper limit tensors, shaped ``(num_envs, n_tendons)``. + The tendon indices as a device tensor, so per-step commands do not upload them, and the + lower / upper limit tensors, shaped ``(num_envs, n_tendons)``. Raises: ValueError: If a requested tendon is not present on the articulation. @@ -281,4 +285,8 @@ def resolve_actuated_tendons( raise ValueError(f"Expected {len(tendon_names)} actuated tendons, found {len(indices)}.") shape = (num_envs, len(indices)) lower, upper = position_limits - return indices, torch.full(shape, lower, device=device), torch.full(shape, upper, device=device) + return ( + torch.tensor(indices, dtype=torch.long, device=device), + torch.full(shape, lower, device=device), + torch.full(shape, upper, device=device), + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py index 4a1bc3aa9b20..7c54f1b83bc2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py @@ -7,6 +7,7 @@ from __future__ import annotations +import functools from typing import TYPE_CHECKING import torch @@ -104,29 +105,8 @@ def _transform_policy_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor) Returns: The transformed observation tensor with left-right symmetry applied. """ - # copy observation tensor - obs = obs.clone() - device = obs.device - # lin vel - obs[:, :3] = obs[:, :3] * torch.tensor([1, -1, 1], device=device) - # ang vel - obs[:, 3:6] = obs[:, 3:6] * torch.tensor([-1, 1, -1], device=device) - # projected gravity - obs[:, 6:9] = obs[:, 6:9] * torch.tensor([1, -1, 1], device=device) - # velocity command - obs[:, 9:12] = obs[:, 9:12] * torch.tensor([1, -1, -1], device=device) - # joint pos - obs[:, 12:24] = _switch_anymal_joints_left_right(obs[:, 12:24]) - # joint vel - obs[:, 24:36] = _switch_anymal_joints_left_right(obs[:, 24:36]) - # last actions - obs[:, 36:48] = _switch_anymal_joints_left_right(obs[:, 36:48]) - - # note: this is hard-coded for grid-pattern of ordering "xy" and size (1.6, 1.0) - if "height_scan" in env.observation_manager.active_terms["policy"]: - obs[:, 48:235] = obs[:, 48:235].view(-1, 11, 17).flip(dims=[1]).view(-1, 11 * 17) - - return obs + perm, sign = _policy_obs_symmetry("left_right", obs.shape[1], _has_height_scan(env), str(obs.device)) + return obs[:, perm] * sign def _transform_policy_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor) -> torch.Tensor: @@ -145,29 +125,8 @@ def _transform_policy_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor) Returns: The transformed observation tensor with front-back symmetry applied. """ - # copy observation tensor - obs = obs.clone() - device = obs.device - # lin vel - obs[:, :3] = obs[:, :3] * torch.tensor([-1, 1, 1], device=device) - # ang vel - obs[:, 3:6] = obs[:, 3:6] * torch.tensor([1, -1, -1], device=device) - # projected gravity - obs[:, 6:9] = obs[:, 6:9] * torch.tensor([-1, 1, 1], device=device) - # velocity command - obs[:, 9:12] = obs[:, 9:12] * torch.tensor([-1, 1, -1], device=device) - # joint pos - obs[:, 12:24] = _switch_anymal_joints_front_back(obs[:, 12:24]) - # joint vel - obs[:, 24:36] = _switch_anymal_joints_front_back(obs[:, 24:36]) - # last actions - obs[:, 36:48] = _switch_anymal_joints_front_back(obs[:, 36:48]) - - # note: this is hard-coded for grid-pattern of ordering "xy" and size (1.6, 1.0) - if "height_scan" in env.observation_manager.active_terms["policy"]: - obs[:, 48:235] = obs[:, 48:235].view(-1, 11, 17).flip(dims=[2]).view(-1, 11 * 17) - - return obs + perm, sign = _policy_obs_symmetry("front_back", obs.shape[1], _has_height_scan(env), str(obs.device)) + return obs[:, perm] * sign """ @@ -189,9 +148,8 @@ def _transform_actions_left_right(actions: torch.Tensor) -> torch.Tensor: Returns: The transformed actions tensor with left-right symmetry applied. """ - actions = actions.clone() - actions[:] = _switch_anymal_joints_left_right(actions[:]) - return actions + perm, sign = _joint_symmetry("left_right", str(actions.device)) + return actions[:, perm] * sign def _transform_actions_front_back(actions: torch.Tensor) -> torch.Tensor: @@ -208,9 +166,8 @@ def _transform_actions_front_back(actions: torch.Tensor) -> torch.Tensor: Returns: The transformed actions tensor with front-back symmetry applied. """ - actions = actions.clone() - actions[:] = _switch_anymal_joints_front_back(actions[:]) - return actions + perm, sign = _joint_symmetry("front_back", str(actions.device)) + return actions[:, perm] * sign """ @@ -229,32 +186,58 @@ def _transform_actions_front_back(actions: torch.Tensor) -> torch.Tensor: * LH = left hind --> [1, 5, 9] * RF = right front --> [2, 6, 10] * RH = right hind --> [3, 7, 11] -""" - -def _switch_anymal_joints_left_right(joint_data: torch.Tensor) -> torch.Tensor: - """Applies a left-right symmetry transformation to the joint data tensor.""" - joint_data_switched = torch.zeros_like(joint_data) - # left <-- right - joint_data_switched[..., [0, 4, 8, 1, 5, 9]] = joint_data[..., [2, 6, 10, 3, 7, 11]] - # right <-- left - joint_data_switched[..., [2, 6, 10, 3, 7, 11]] = joint_data[..., [0, 4, 8, 1, 5, 9]] - - # Flip the sign of the HAA joints - joint_data_switched[..., [0, 1, 2, 3]] *= -1.0 - - return joint_data_switched - - -def _switch_anymal_joints_front_back(joint_data: torch.Tensor) -> torch.Tensor: - """Applies a front-back symmetry transformation to the joint data tensor.""" - joint_data_switched = torch.zeros_like(joint_data) - # front <-- hind - joint_data_switched[..., [0, 4, 8, 2, 6, 10]] = joint_data[..., [1, 5, 9, 3, 7, 11]] - # hind <-- front - joint_data_switched[..., [1, 5, 9, 3, 7, 11]] = joint_data[..., [0, 4, 8, 2, 6, 10]] - - # Flip the sign of the HFE and KFE joints - joint_data_switched[..., 4:] *= -1 +Each transform is a column permutation and a sign per column: ``out[:, i] = sign[i] * x[:, perm[i]]``. +""" - return joint_data_switched +# signs of the base lin vel, ang vel, projected gravity, and velocity command columns +_BASE_SIGNS = { + "left_right": (1, -1, 1, -1, 1, -1, 1, -1, 1, 1, -1, -1), + "front_back": (-1, 1, 1, 1, -1, -1, -1, 1, 1, -1, 1, -1), +} +# source joint of each switched joint: left <-> right swaps LF/RF and LH/RH, front <-> back swaps LF/LH and RF/RH +_JOINT_PERMS = { + "left_right": (2, 3, 0, 1, 6, 7, 4, 5, 10, 11, 8, 9), + "front_back": (1, 0, 3, 2, 5, 4, 7, 6, 9, 8, 11, 10), +} +# left <-> right flips the HAA joints, front <-> back flips the HFE and KFE joints +_JOINT_SIGNS = { + "left_right": (-1,) * 4 + (1,) * 8, + "front_back": (1,) * 4 + (-1,) * 8, +} +# height-scan grid dimension flipped per transform, for the (11, 17) grid +_HEIGHT_SCAN_FLIP_DIM = {"left_right": 0, "front_back": 1} + + +def _has_height_scan(env: ManagerBasedRLEnv) -> bool: + """Whether the policy observation contains the height scan.""" + return "height_scan" in env.observation_manager.active_terms["policy"] + + +@functools.cache +def _joint_symmetry(kind: str, device: str) -> tuple[torch.Tensor, torch.Tensor]: + """Column permutation and signs of a transform over the 12 joints, cached per device.""" + return ( + torch.tensor(_JOINT_PERMS[kind], dtype=torch.long, device=device), + torch.tensor(_JOINT_SIGNS[kind], device=device), + ) + + +@functools.cache +def _policy_obs_symmetry(kind: str, num_obs: int, height_scan: bool, device: str) -> tuple[torch.Tensor, torch.Tensor]: + """Column permutation and signs of a transform over the policy observation, cached per device.""" + perm = list(range(num_obs)) + sign = [1] * num_obs + sign[:12] = _BASE_SIGNS[kind] + # joint positions, joint velocities, and last actions + for start in (12, 24, 36): + perm[start : start + 12] = [start + joint for joint in _JOINT_PERMS[kind]] + sign[start : start + 12] = _JOINT_SIGNS[kind] + # note: this is hard-coded for grid-pattern of ordering "xy" and size (1.6, 1.0) + if height_scan: + grid = torch.arange(48, 235).view(11, 17).flip(dims=[_HEIGHT_SCAN_FLIP_DIM[kind]]) + perm[48:235] = grid.flatten().tolist() + return ( + torch.tensor(perm, dtype=torch.long, device=device), + torch.tensor(sign, device=device), + ) diff --git a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py new file mode 100644 index 000000000000..36c35ad3cbe3 --- /dev/null +++ b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py @@ -0,0 +1,90 @@ +# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the ANYmal symmetry augmentation.""" + +from types import SimpleNamespace + +import pytest +import torch +from tensordict import TensorDict + +from isaaclab_tasks.core.velocity.mdp.symmetry import anymal + +# Reference: the index-assignment formulation the permutation tables must reproduce. +_LEFT = [0, 4, 8, 1, 5, 9] +_RIGHT = [2, 6, 10, 3, 7, 11] +_FRONT = [0, 4, 8, 2, 6, 10] +_HIND = [1, 5, 9, 3, 7, 11] + + +def _reference_joints(joint_data: torch.Tensor, kind: str) -> torch.Tensor: + first, second = (_LEFT, _RIGHT) if kind == "left_right" else (_FRONT, _HIND) + switched = torch.zeros_like(joint_data) + switched[..., first] = joint_data[..., second] + switched[..., second] = joint_data[..., first] + if kind == "left_right": + switched[..., [0, 1, 2, 3]] *= -1.0 + else: + switched[..., 4:] *= -1 + return switched + + +def _reference_obs(obs: torch.Tensor, kind: str, height_scan: bool) -> torch.Tensor: + signs = { + "left_right": ([1, -1, 1], [-1, 1, -1], [1, -1, 1], [1, -1, -1]), + "front_back": ([-1, 1, 1], [1, -1, -1], [-1, 1, 1], [-1, 1, -1]), + }[kind] + obs = obs.clone() + for block, sign in enumerate(signs): + obs[:, 3 * block : 3 * block + 3] = obs[:, 3 * block : 3 * block + 3] * torch.tensor(sign) + for start in (12, 24, 36): + obs[:, start : start + 12] = _reference_joints(obs[:, start : start + 12], kind) + if height_scan: + flip_dim = 1 if kind == "left_right" else 2 + obs[:, 48:235] = obs[:, 48:235].view(-1, 11, 17).flip(dims=[flip_dim]).view(-1, 11 * 17) + return obs + + +def _reference_augment(obs: torch.Tensor, actions: torch.Tensor, height_scan: bool): + left_right = _reference_obs(obs, "left_right", height_scan) + obs_aug = torch.cat( + ( + obs, + left_right, + _reference_obs(obs, "front_back", height_scan), + _reference_obs(left_right, "front_back", height_scan), + ) + ) + actions_left_right = _reference_joints(actions, "left_right") + actions_aug = torch.cat( + ( + actions, + actions_left_right, + _reference_joints(actions, "front_back"), + _reference_joints(actions_left_right, "front_back"), + ) + ) + return obs_aug, actions_aug + + +@pytest.mark.parametrize("height_scan", [False, True]) +def test_compute_symmetric_states_matches_reference(height_scan: bool): + """Verify the cached permutation and sign tables reproduce the index-assignment formulation.""" + num_envs = 5 + num_obs = 235 if height_scan else 48 + obs = torch.randn(num_envs, num_obs) + actions = torch.randn(num_envs, 12) + active_terms = ["base_lin_vel", "height_scan"] if height_scan else ["base_lin_vel"] + unwrapped = SimpleNamespace(observation_manager=SimpleNamespace(active_terms={"policy": active_terms})) + env = SimpleNamespace(unwrapped=unwrapped) + + obs_aug, actions_aug = anymal.compute_symmetric_states( + env, obs=TensorDict({"policy": obs}, batch_size=[num_envs]), actions=actions + ) + expected_obs, expected_actions = _reference_augment(obs, actions, height_scan) + + assert torch.equal(obs_aug["policy"], expected_obs) + assert torch.equal(actions_aug, expected_actions) diff --git a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py index 05f206cf6aa8..5aadcb08de89 100644 --- a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py +++ b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py @@ -13,6 +13,7 @@ from pxr import Usd +from isaaclab.assets import Asset from isaaclab.managers import CommandTerm, ObservationTermCfg, SceneEntityCfg from isaaclab.sim import select_usd_variants from isaaclab.utils.warp import ProxyArray @@ -146,9 +147,10 @@ def test_camera_normalization_is_stationary(data_type: str) -> None: torch.testing.assert_close(output.flatten(), expected) -def test_lift_pose_markers_forward_environment_ids(monkeypatch: pytest.MonkeyPatch) -> None: - """Every lift pose, goal, and success marker should retain its environment ownership.""" - num_envs = 3 +def _make_pose_command( + monkeypatch: pytest.MonkeyPatch, num_envs: int, success_asset: object +) -> tuple[ObjectUniformPoseCommand, torch.Tensor]: + """Build a pose command around fake assets and spy markers; returns it with the shared root positions.""" environment_ids = torch.arange(num_envs) identity_quat = torch.zeros((num_envs, 4)) identity_quat[:, 3] = 1.0 @@ -169,7 +171,6 @@ def test_lift_pose_markers_forward_environment_ids(monkeypatch: pytest.MonkeyPat root_link_pose_w=SimpleNamespace(torch=root_pose_w), ) ) - success_asset = SimpleNamespace(data=SimpleNamespace(root_pos_w=SimpleNamespace(torch=root_pos_w))) scene = _FakeScene(environment_ids, robot=robot, object=object_asset, table=success_asset) env = SimpleNamespace(num_envs=num_envs, device="cpu", scene=scene) cfg = SimpleNamespace( @@ -191,20 +192,36 @@ def _initialize_command_term(command, command_cfg, command_env) -> None: monkeypatch.setattr(CommandTerm, "__init__", _initialize_command_term) monkeypatch.setattr(pose_commands, "VisualizationMarkers", _MarkerSpy) + return ObjectUniformPoseCommand(cfg, env), root_pos_w - command = ObjectUniformPoseCommand(cfg, env) + +@pytest.mark.parametrize("static", [False, True]) +def test_lift_pose_markers_forward_environment_ids(monkeypatch: pytest.MonkeyPatch, static: bool) -> None: + """Every lift pose, goal, and success marker should retain its environment ownership.""" + num_envs = 3 + environment_ids = torch.arange(num_envs) + success_asset = SimpleNamespace(data=SimpleNamespace(root_pos_w=SimpleNamespace(torch=torch.zeros((num_envs, 3))))) + if static: + success_asset = object.__new__(Asset) + success_asset.cfg = SimpleNamespace(init_state=SimpleNamespace(pos=(0.5, 0.0, 0.0))) + command, root_pos_w = _make_pose_command(monkeypatch, num_envs, success_asset) command._set_debug_vis_impl(True) command._debug_vis_callback(None) command.cfg.position_only = False command._debug_vis_callback(None) + # Discard construction-time placement, as happens before a backend is active. + command.success_visualizer.calls.clear() + command.cfg.position_only = True + command.pose_command_b[:, 0] = torch.tensor([1.0, 0.0, 1.0]) command._update_metrics() + assert torch.equal(command.success_visualizer.calls[0][1]["marker_indices"], torch.tensor([0, 1, 0])) DeformableUniformPoseCommand._update_metrics(command) command._segment_position_w = lambda: root_pos_w CableUniformPoseCommand._update_metrics(command) CableUniformPoseCommand._debug_vis_callback(command, None) expected_call_counts = { - command.success_visualizer: 4, + command.success_visualizer: 3, command.goal_visualizer: 3, command.curr_visualizer: 3, } @@ -212,6 +229,29 @@ def _initialize_command_term(command, command_cfg, command_env) -> None: assert len(visualizer.calls) == expected_count for _, kwargs in visualizer.calls: assert torch.equal(kwargs["environment_ids"], environment_ids) + if static: + for args, _ in command.success_visualizer.calls: + torch.testing.assert_close(args[0], torch.tensor([[0.5, 0.0, 0.0]] * num_envs)) + + +def test_lift_contact_terms_match_per_sensor_reference() -> None: + """Contact gating and counting from stacked sensor forces match a per-sensor evaluation.""" + num_envs, threshold = 3, 1.0 + names = ["thumb", "index", "middle"] + # The rows cover a finger without thumb, thumb with finger, and thumb alone. + magnitudes = {"thumb": [0.0, 2.0, 2.0], "index": [2.0, 0.0, 0.0], "middle": [0.0, 2.0, 0.0]} + forces = { + name: torch.tensor([[value, 0.0, 0.0] for value in values]).reshape(num_envs, 1, 1, 3) + for name, values in magnitudes.items() + } + sensors = { + name: SimpleNamespace(data=SimpleNamespace(normal_force_matrix_w=ProxyArray(wp.from_torch(force)))) + for name, force in forces.items() + } + env = SimpleNamespace(num_envs=num_envs, device="cpu", scene=SimpleNamespace(sensors=sensors)) + assert torch.equal(mdp.contacts(env, threshold, "thumb", names[1:]), torch.tensor([False, True, False])) + torch.testing.assert_close(mdp.contact_count(env, threshold, names), torch.tensor([1 / 3, 2 / 3, 1 / 3])) + assert not mdp.contacts(env, threshold, "thumb", []).any() def test_lift_point_cloud_markers_repeat_environment_ids_per_point() -> None: diff --git a/source/isaaclab_tasks/test/core/test_reorient_utils.py b/source/isaaclab_tasks/test/core/test_reorient_utils.py index 5eff5ca1e7df..f13d6ecc7ab6 100644 --- a/source/isaaclab_tasks/test/core/test_reorient_utils.py +++ b/source/isaaclab_tasks/test/core/test_reorient_utils.py @@ -62,10 +62,24 @@ def test_episode_error_recorder_reports_threshold_independent_statistics(): def test_episode_error_recorder_skips_episodes_without_samples(): - """Verify initial resets do not emit non-finite diagnostic values.""" - recorder = reorient_utils.EpisodeErrorRecorder(num_envs=2, device="cpu") + """Initial resets are empty; later statistics exclude unsampled episodes.""" + recorder = reorient_utils.EpisodeErrorRecorder(num_envs=5, device="cpu") + assert recorder.reset(torch.arange(5)) == {} - assert recorder.reset(torch.tensor([0, 1])) == {} + nan = float("nan") + # envs 1 and 4 only see non-finite errors, so they have no sample + recorder.update(torch.tensor([0.3, nan, 0.1, 0.4, torch.inf])) + recorder.update(torch.tensor([0.2, nan, 0.5, 0.35, nan])) + + statistics = recorder.reset(torch.tensor([0, 1, 2, 3])) + sampled = torch.tensor([0.2, 0.1, 0.35]) + assert {k: v.item() for k, v in statistics.items()} == pytest.approx( + {"mean": sampled.mean().item(), "median": sampled.median().item(), "p90": torch.quantile(sampled, 0.9).item()} + ) + + statistics = recorder.reset(torch.tensor([4])) + assert set(statistics) == {"mean", "median", "p90"} + assert all(torch.isnan(value) for value in statistics.values()) def test_episode_error_recorder_update_matches_masked_indexing_reference(): @@ -163,7 +177,7 @@ def test_resolve_actuated_tendons_returns_indices_in_action_order_and_limit_tens hand, ["rh_LFJ0", "rh_FFJ0"], num_envs=3, device="cpu", position_limits=(0.0, 2.0) ) - assert indices == [3, 0] + assert torch.equal(indices, torch.tensor([3, 0])) assert torch.equal(lower, torch.zeros(3, 2)) assert torch.equal(upper, torch.full((3, 2), 2.0)) diff --git a/source/isaaclab_tasks_experimental/changelog.d/towncrier-migration.skip b/source/isaaclab_tasks_experimental/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks_experimental/docs/CHANGELOG.rst b/source/isaaclab_tasks_experimental/docs/CHANGELOG.rst new file mode 100644 index 000000000000..44dabc439d36 --- /dev/null +++ b/source/isaaclab_tasks_experimental/docs/CHANGELOG.rst @@ -0,0 +1,4 @@ +Changelog +--------- + +.. towncrier release notes start diff --git a/source/isaaclab_teleop/changelog.d/towncrier-migration.skip b/source/isaaclab_teleop/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_teleop/docs/CHANGELOG.rst b/source/isaaclab_teleop/docs/CHANGELOG.rst index 90e98bec2514..0c3269117932 100644 --- a/source/isaaclab_teleop/docs/CHANGELOG.rst +++ b/source/isaaclab_teleop/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 0.10.0 (2026-09-29) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_visualizers/changelog.d/towncrier-migration.skip b/source/isaaclab_visualizers/changelog.d/towncrier-migration.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_visualizers/docs/CHANGELOG.rst b/source/isaaclab_visualizers/docs/CHANGELOG.rst index bd460f2ffdac..e052312597c4 100644 --- a/source/isaaclab_visualizers/docs/CHANGELOG.rst +++ b/source/isaaclab_visualizers/docs/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog --------- +.. towncrier release notes start + 2.0.0 (2026-09-28) ~~~~~~~~~~~~~~~~~~ diff --git a/tools/changelog/cli.py b/tools/changelog/cli.py index af1b566123d3..d028afe4c0c7 100644 --- a/tools/changelog/cli.py +++ b/tools/changelog/cli.py @@ -3,63 +3,16 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Manage changelog fragments — single entry point with two subcommands. +"""Check and compile per-package changelog fragments with towncrier and uv. -Each PR drops a fragment under ``source//changelog.d/.rst``. -The slug is any short, unique name — the contributor's branch name (with -``/`` replaced by ``-``) is the recommended default. The file mirrors -the RST that will appear in the changelog — one or more section headings -(``Added``, ``Changed``, ``Deprecated``, ``Removed``, ``Fixed``) each -underlined with ``^``. The **filename suffix** declares the bump tier: +Each PR adds ``source//changelog.d/..rst`` per entry type (``added``, ``changed``, +``deprecated``, ``removed``, ``fixed``) holding ``* `` bullets, or an empty ``.skip`` for no +entry. An empty ``.minor`` or ``.major`` raises the version bump above patch. -- ``.rst`` — patch bump. -- ``.minor.rst`` — minor bump. -- ``.major.rst`` — major bump. -- ``.skip`` — no entry, no bump. +Usage:: -When a batch compiles together, the highest declared bump wins for the -package (one ``.major.rst`` anywhere → major). - -Subcommands: - - check PR gate. Verifies every modified package has a valid fragment. - compile Roll accumulated fragments into ``CHANGELOG.rst`` and bump - ``extension.toml``. Run by the nightly workflow - (``.github/workflows/nightly-changelog.yml``) on a cron and - by maintainers manually when cutting a release. - -Usage: - - # ── check ───────────────────────────────────────────────────── - # CI invocation on every pull_request: - cli.py check - - # Local invocation used by pre-commit (defaults to develop): - cli.py check --include-worktree - - # Override the local base for a release branch: - ISAACLAB_CHANGELOG_BASE_REF=release/6.0 cli.py check --include-worktree - - # ── compile ─────────────────────────────────────────────────── - # Normal release-time invocation — bump every managed package - # from accumulated fragments, write entries, delete fragments: - cli.py compile --all - - # Preview only (no writes, no deletes): - cli.py compile --all --dry-run - - # Pin one package to a specific version (single-package only — - # each managed package has its own version trajectory): - cli.py compile --package isaaclab --version 4.7.0 - - # Preview against a worked example without touching real packages: - cli.py compile --package isaaclab --dry-run \\ - --fragments-dir tools/changelog/test/integration/02_minor_bump/fragments - -For big version jumps (e.g. ``2.1`` → ``4.7``) edit -``source//config/extension.toml`` and prepend a manual entry to -``source//docs/CHANGELOG.rst``. The compiler is for fragment-driven -incremental bumps, not for jumps. + cli.py check [] [--include-worktree] # PR gate, run by pre-commit and CI + cli.py compile # nightly: bump, build CHANGELOG.rst, re-lock """ from __future__ import annotations @@ -69,1077 +22,305 @@ import re import subprocess import sys -from dataclasses import dataclass, field -from datetime import date -from functools import cached_property from pathlib import Path -from typing import ClassVar - -# Walk three levels up: tools/changelog/cli.py -> tools/changelog/ -> tools/ -> repo root. -REPO_ROOT = Path(__file__).parent.parent.parent -PACKAGES_ROOT = REPO_ROOT / "source" - -# Recognised fragment filename patterns. ```` is any short identifier -# the contributor chose — typically their branch name with ``/`` replaced by -# ``-``. The slug must not contain ``.`` (reserved for the tier suffix) or -# ``/`` (path separator), but otherwise mirrors what git allows in a ref name. -# These regexes live at module level because Fragment, FragmentBatch, and -# PRDiff all match against them — they are the wire-format contract between -# contributors and the gate. -FRAGMENT_RE = re.compile(r"^(?P[^./][^./]*)(?:\.(?Pminor|major))?\.rst$") -SKIP_RE = re.compile(r"^(?P[^./][^./]*)\.skip$") -# Anchor the compile-time insertion point in ``CHANGELOG.rst``. A managed -# package's file must contain at minimum ``Changelog\n---+\n\n`` — header, -# underline, then a blank line — so the bot has a place to prepend the next -# version block. Imported by both ``Package.write_changelog_entry`` (the -# producer) and ``test_validate`` (the regression gate) so the two cannot -# drift on what "valid header" means. -CHANGELOG_HEADER_RE = re.compile(r"^Changelog\n-+\s*\n\s*\n", re.MULTILINE) +import tomllib +REPO_ROOT = Path(__file__).resolve().parents[2] +CONFIG = Path(__file__).resolve().parent / "towncrier.toml" +TOWNCRIER = ("uvx", "--from", "towncrier==26.9.0", "towncrier") +TYPES = ("added", "changed", "deprecated", "removed", "fixed") +ENTRY_RE = re.compile(rf"^[^./]+\.({'|'.join(TYPES)})\.rst$") +MARKER_RE = re.compile(r"^[^./]+\.(skip|minor|major)$") +RELEASE_NOTES_MARKER = ".. towncrier release notes start" -def _display_path(p: Path) -> str: - """Pretty-print a Path. Strips ``REPO_ROOT`` if ``p`` is inside the repo, - falls back to the absolute path otherwise (``--fragments-dir`` may - legitimately point at an external directory like ``/tmp/...``). - Lives at module level because both :class:`Package` (writing on-disk - paths) and :class:`FragmentBatch` (warning about external fragment - paths) use it. - """ - try: - return str(p.relative_to(REPO_ROOT)) - except ValueError: - return str(p) +# --------------------------------------------------------------------------------------------------- +# shared helpers +# --------------------------------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# Domain objects -# --------------------------------------------------------------------------- +def run(*cmd: str | Path, env: dict[str, str] | None = None) -> str: + """Run ``cmd`` in the repository root and return its stdout; raise on a non-zero exit.""" + return subprocess.run( + [str(arg) for arg in cmd], cwd=REPO_ROOT, env=env, capture_output=True, text=True, check=True + ).stdout -@dataclass(frozen=True) -class Version: - """A semver-style version string ``X.Y.Z`` (optionally suffixed with ``.devN``). - - Models a version as a value object: immutable, comparable by its text, - knows how to produce a bumped successor. PEP 440 ``.devN`` suffixes - are tolerated on the way *in* (stripped before bumping) but never - written back out — :meth:`bumped` always returns a clean ``X.Y.Z``. - - Construction validates the format up front so that an invalid - ``--version`` flag from the CLI fails fast instead of silently writing - a malformed entry to ``CHANGELOG.rst``. - """ - - # ``X.Y.Z`` with an optional PEP 440 ``.devN`` suffix. The suffix is - # tolerated on the way *in* (e.g. when reading a stale dev version out - # of an existing ``extension.toml``) but :meth:`bumped` always strips - # it before producing a successor. - _SEMVER_RE: ClassVar[re.Pattern[str]] = re.compile(r"^\d+\.\d+\.\d+(\.dev\d+)?$") +def packages() -> list[Path]: + """Return the package directories that keep a ``docs/CHANGELOG.rst``.""" + return sorted(changelog.parent.parent for changelog in (REPO_ROOT / "source").glob("*/docs/CHANGELOG.rst")) - text: str - def __post_init__(self) -> None: - if not self._SEMVER_RE.match(self.text): - raise ValueError(f"Invalid version {self.text!r}; expected X.Y.Z (optionally suffixed with .devN)") +# --------------------------------------------------------------------------------------------------- +# legacy fragments (WAR): the check rejects the pre-towncrier format, but fragments merged before it, or +# without re-running CI, still reach the nightly, so compile splits them. Delete this section and its +# ``# WAR`` caller once no pending fragment uses the old format. +# --------------------------------------------------------------------------------------------------- - def bumped(self, tier: str) -> Version: - """Return a new Version one tier ahead of this one. +LEGACY_RE = re.compile(r"^(?P[^./]+)(?:\.(?Pminor|major))?\.rst$") +LEGACY_HEADING_RE = re.compile(r"^(\S[^\n]*)\n\^+[ \t]*\n", re.MULTILINE) - ``tier`` is ``'major'``, ``'minor'``, or ``'patch'``. Major zeros - the minor and patch components; minor zeros patch. Any ``.devN`` - suffix on the current version is stripped before bumping. - """ - # __post_init__ guarantees the format, so this split is safe. - parts = self.text.split(".dev")[0].split(".") - if tier == "major": - return Version(f"{int(parts[0]) + 1}.0.0") - if tier == "minor": - return Version(f"{parts[0]}.{int(parts[1]) + 1}.0") - return Version(f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}") - def __str__(self) -> str: - return self.text +def split_legacy(path: Path) -> dict[str, str]: + """Return the towncrier fragments ``{name: text}`` equivalent to a legacy fragment. + A legacy ``[.minor|.major].rst`` holds ``^``-underlined Added/Changed/Deprecated/Removed/Fixed + sections. Each section becomes ``..rst`` and the tier an empty ``.minor``/``.major``. -@dataclass(frozen=True) -class Fragment: - """One fragment file in a package's ``changelog.d/`` (or an examples dir). - - A :class:`Fragment` instance is just a path plus methods that interpret - it as a changelog fragment. ``.gitkeep`` and ``*.skip`` files should - not be wrapped — only files matching :data:`FRAGMENT_RE`. + Raises: + ValueError: If the file has no sections, or a section is unknown or repeated. """ - - path: Path - - @property - def name(self) -> str: - return self.path.name - - @cached_property - def _match(self) -> re.Match[str] | None: - return FRAGMENT_RE.match(self.name) - - @property - def is_valid_filename(self) -> bool: - return self._match is not None - - @property - def bump(self) -> str: - """Bump tier declared by the filename suffix (defaults to ``'patch'``).""" - if self._match and self._match.group("bump"): - return self._match.group("bump") - return "patch" - - def parse(self) -> dict[str, list[str]]: - """Return ``{section: [lines]}`` from this fragment's content. - - Lines are kept as-is (including trailing newlines) so the compiled - output is byte-for-byte identical to what the contributor wrote. A - section heading is a non-empty line followed by ``^`` underline of - equal-or-greater length. - """ - text = self.path.read_text(encoding="utf-8") - lines = text.splitlines(keepends=True) - sections: dict[str, list[str]] = {} - current: str | None = None - buf: list[str] = [] - - i = 0 - while i < len(lines): - raw = lines[i] - stripped = raw.rstrip("\n") - if ( - i + 1 < len(lines) - and stripped - and re.fullmatch(r"\^+", lines[i + 1].rstrip("\n")) - and len(lines[i + 1].rstrip("\n")) >= len(stripped) - ): - if current is not None: - sections[current] = self._strip_trailing_blank(buf) - current = stripped - buf = [] - i += 2 # skip heading + underline - if i < len(lines) and not lines[i].strip(): - i += 1 - continue - if current is not None: - buf.append(raw) - i += 1 - - if current is not None: - sections[current] = self._strip_trailing_blank(buf) - - return sections - - @staticmethod - def _strip_trailing_blank(lines: list[str]) -> list[str]: - """Drop trailing blank lines from a section's raw line buffer.""" - while lines and not lines[-1].strip(): - lines.pop() - return lines - - @staticmethod - def parse_slug(filename: str) -> str | None: - """Return the slug declared by a fragment / skip filename, or ``None``. - - Used by :class:`PRDiff` to detect collisions between an added - fragment's slug and an existing fragment in the same directory, - without needing to materialise a :class:`Fragment` (the diff entry - may not exist on disk yet during a gate run). - """ - m = FRAGMENT_RE.match(filename) or SKIP_RE.match(filename) - return m.group("slug") if m else None - - def merge_time(self) -> int: - """Unix timestamp of the merge commit that introduced this fragment. - - Uses ``git log --diff-filter=A --first-parent`` to follow develop's - first-parent history, so the timestamp reflects when the PR's merge - commit landed (not the feature-branch commit that originally added - the file). Falls back to the file's most recent commit time when - not yet in first-parent history (e.g. local dry-runs on a feature - branch), and ultimately to ``0`` if git is unavailable. - """ - for cmd in ( - ["git", "log", "--diff-filter=A", "--first-parent", "-1", "--format=%ct", "--", str(self.path)], - ["git", "log", "-1", "--format=%ct", "--", str(self.path)], - ): - try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True, cwd=REPO_ROOT) - ts = result.stdout.strip() - if ts: - return int(ts) - except (subprocess.CalledProcessError, ValueError): - continue - return 0 - - def validate(self) -> str | None: - """Return a human-readable error string if malformed, else ``None``. - - Filename rules: must match :data:`FRAGMENT_RE` (``.gitkeep`` and - ``*.skip`` files are filtered out at :meth:`FragmentBatch.from_dir` - level and never reach this method). Content rules (for ``*.rst`` - fragments only): non-empty file with at least one valid section - heading and at least one bullet point. - """ - if not self.is_valid_filename: - return ( - "invalid filename — must be .rst, .minor.rst, " - ".major.rst, or .skip (slug = your branch name " - "with `/` replaced by `-`, no dots)" - ) - if not self.path.exists(): - # Deleted fragments don't need validating (consumed by a previous compile). - return None - text = self.path.read_text(encoding="utf-8") - if not text.strip(): - return "fragment is empty" - sections = self.parse() - if not sections: - return ( - "no recognised section headings (expected one or more of " - "Added / Changed / Deprecated / Removed / Fixed, each underlined " - "with carets ``^`` of equal-or-greater length)" - ) - # Every declared section must carry at least one bullet — otherwise - # the compiled output emits a heading with no body, which is both - # ugly and almost certainly a contributor authoring mistake (typed - # the heading, forgot the bullet). - empty = [s for s, lines in sections.items() if not any(line.lstrip().startswith("*") for line in lines)] - if empty: - return ( - f"section(s) {', '.join(repr(s) for s in empty)} have no bullet entries — " - "use ``* `` to start each entry, or remove the heading" - ) - # Every line inside a section body must be a bullet (``* ``), a - # continuation (leading whitespace), or blank. A column-0 non-blank - # line that isn't a bullet terminates the list under RST rules and - # then sits as a paragraph adjacent to the next ``* `` — which the - # compile step splices into ``CHANGELOG.rst`` under the same - # ``^^^`` subheading and Sphinx then rejects with - # ``Unexpected indentation``. Catch it here before merge. - for section, lines in sections.items(): - for offset, line in enumerate(lines): - if not line.strip(): - continue - if line[0].isspace() or line.lstrip().startswith("*"): - continue - snippet = line.strip()[:80] - return ( - f"section {section!r} contains an orphan paragraph " - f"(non-bullet line {offset + 1}: {snippet!r}). Every line under " - "a section heading must start with ``* `` (new bullet) or whitespace " - "(continuation of the previous bullet). A flush-left paragraph here " - "splits the bullet list and Sphinx fails the doc build with " - "``Unexpected indentation``." - ) - return None - - -@dataclass(frozen=True) -class FragmentBatch: - """A collection of fragments collected from a directory. - - ``valid`` are :class:`Fragment` instances sorted by merge time - (oldest first). ``invalid`` are paths that don't match any recognised - filename pattern — surfaced so the caller can warn or fail. ``.skip`` - and ``.gitkeep`` files are tolerated but excluded from both lists. - - Holds the pure-data class methods that turn a batch (or a synthetic - list of bumps / sections) into a compiled changelog entry. The - instance methods (:meth:`aggregate_bump`, :meth:`merged_sections`, - :meth:`compile_to_entry`) read the batch's own state; the - underscore-prefixed static methods (:meth:`_aggregate`, etc.) are - the underlying pure transformations and are used directly by tests - that exercise edge cases without a real fragments directory. + match = LEGACY_RE.match(path.name) + parts = LEGACY_HEADING_RE.split(path.read_text(encoding="utf-8")) + headings = [fragment_type.capitalize() for fragment_type in TYPES] + if parts[0].strip() or len(parts) < 3: + raise ValueError(f"expected sections {', '.join(headings)} underlined with ^") + fragments = {f"{match['slug']}.{match['tier']}": ""} if match["tier"] else {} + for heading, body in zip(parts[1::2], parts[2::2]): + name = f"{match['slug']}.{heading.lower()}.rst" + if heading not in headings or name in fragments: + raise ValueError(f"unknown or repeated section {heading!r}") + fragments[name] = body.strip("\n") + "\n" + return fragments + + +def split_legacy_fragments(fragments: Path) -> None: + """Replace each legacy fragment in the ``fragments`` directory with its towncrier fragments. + + Raises: + ValueError: If a legacy fragment is malformed or its split would overwrite another fragment. """ + for path in sorted(fragments.glob("*.rst")): + if LEGACY_RE.match(path.name): + for name, text in split_legacy(path).items(): + # Another fragment with the same slug would otherwise lose its entry. + if (fragments / name).exists(): + raise ValueError(f"splitting {path.name} would overwrite {name}") + (fragments / name).write_text(text, encoding="utf-8") + path.unlink() - # Canonical ordering of section headings in compiled output. Anything - # not listed here keeps insertion order *after* these. - _SECTION_ORDER: ClassVar[list[str]] = ["Added", "Changed", "Deprecated", "Removed", "Fixed"] - - # Strict ordering of bump tiers (``major`` strictly outranks ``minor`` - # outranks ``patch``). Unrecognised tiers sort below ``patch``. - _BUMP_RANK: ClassVar[dict[str, int]] = {"patch": 0, "minor": 1, "major": 2} - - valid: list[Fragment] - invalid: list[Path] - skip_paths: list[Path] = field(default_factory=list) - - # ---- Construction -------------------------------------------------- - - @classmethod - def from_dir(cls, fragment_dir: Path) -> FragmentBatch: - if not fragment_dir.is_dir(): - return cls([], []) - valid: list[Fragment] = [] - invalid: list[Path] = [] - skips: list[Path] = [] - for p in fragment_dir.iterdir(): - if p.is_dir() or p.name == ".gitkeep": - continue - if SKIP_RE.match(p.name): - skips.append(p) - continue - f = Fragment(p) - if f.is_valid_filename: - valid.append(f) - else: - invalid.append(p) - # Sort by merge time, breaking ties on filename so the compiled output - # is deterministic when fragments share a merge commit (or when none - # are in git history yet — e.g. a local dry-run against test fixtures). - valid.sort(key=lambda f: (f.merge_time(), f.name)) - return cls(valid, invalid, skips) - - # ---- Queries against this batch's state --------------------------- - - def aggregate_bump(self) -> str: - """Highest bump tier declared by fragments that parsed to content. - - Empty fragments (which the compiler warns about and skips) are - excluded so they don't influence the version. Defaults to - ``patch`` if nothing parsed. - """ - return self._aggregate([f.bump for f, _ in self.parsed()]) - - def parsed(self) -> list[tuple[Fragment, dict[str, list[str]]]]: - """Return ``(fragment, sections)`` pairs, dropping fragments that parse empty.""" - return [(f, s) for f, s in ((f, f.parse()) for f in self.valid) if s] - - def merged_sections(self) -> dict[str, list[str]]: - """Cross-fragment merged section map for this batch.""" - return self._merge_sections([s for _, s in self.parsed()]) - - def compile_to_entry( - self, - current_version: Version, - *, - explicit_version: Version | None = None, - ) -> tuple[Version, str, str]: - """Return ``(new_version, bump_label, entry_text)`` for this batch. - ``new_version`` is either ``explicit_version`` verbatim or the - result of bumping ``current_version`` by the aggregated tier. - ``bump_label`` is a human-readable suffix like ``" (bump: minor)"`` - for log lines (empty when ``explicit_version`` is used). - ``entry_text`` is the rendered RST block ready to prepend to a - ``CHANGELOG.rst``. Pure computation — no I/O. - """ - if explicit_version is not None: - new_version = explicit_version - bump_label = "" - else: - chosen_bump = self.aggregate_bump() - new_version = current_version.bumped(chosen_bump) - bump_label = f" (bump: {chosen_bump})" - entry = self._format_entry(new_version.text, self.merged_sections()) - return new_version, bump_label, entry - - # ---- Cleanup ------------------------------------------------------- - - def delete_all(self) -> tuple[int, int]: - """Delete every consumed fragment + skip file. Returns ``(n_frag, n_skip)``.""" - n_frag = self.delete_valid() - n_skip = self.delete_skips() - return n_frag, n_skip - - def delete_valid(self) -> int: - for f in self.valid: - f.path.unlink() - return len(self.valid) - - def delete_skips(self) -> int: - for p in self.skip_paths: - p.unlink() - return len(self.skip_paths) - - # ---- Pure transformations (the data class methods) ---------------- - # Static so callers and tests can exercise them with synthetic - # primitives — no FragmentBatch instance needed when the question - # is "given these tiers, which wins?" or "how do these dicts merge?" - - @classmethod - def _aggregate(cls, bumps: list[str]) -> str: - """Highest-ranked bump from ``bumps`` (``major > minor > patch``). - - An empty list defaults to ``'patch'``. - """ - if not bumps: - return "patch" - return max(bumps, key=lambda b: cls._BUMP_RANK.get(b, -1)) - - @staticmethod - def _merge_sections(fragments: list[dict[str, list[str]]]) -> dict[str, list[str]]: - """Merge multiple parsed fragments into a single section map. +# --------------------------------------------------------------------------------------------------- +# check: the PR gate +# --------------------------------------------------------------------------------------------------- - Bullets from different fragments that share a section heading are - concatenated directly (no blank line between them) to match the - dominant style in IsaacLab's existing ``CHANGELOG.rst`` files. - """ - merged: dict[str, list[str]] = {} - for frag in fragments: - for section, lines in frag.items(): - if section not in merged: - merged[section] = list(lines) - else: - merged[section].extend(lines) - return merged - @classmethod - def _format_entry(cls, version: str, sections: dict[str, list[str]]) -> str: - """Return a complete RST version entry, ready to prepend to ``CHANGELOG.rst``. +def check_bullet_list(text: str) -> str | None: + """Return why a fragment body is not a ``* `` bullet list, or ``None``. - Sections appear in :attr:`_SECTION_ORDER` (Added, Changed, - Deprecated, Removed, Fixed). Anything else keeps insertion order - *after* the canonical ones. - """ - today = date.today().strftime("%Y-%m-%d") - heading = f"{version} ({today})" - out = [heading, "~" * len(heading), ""] - - ordered = [s for s in cls._SECTION_ORDER if s in sections] - extras = [s for s in sections if s not in cls._SECTION_ORDER] - - for section in ordered + extras: - out.append(section) - out.append("^" * len(section)) - out.append("") - for line in sections[section]: - out.append(line.rstrip("\n")) - out.append("") - - return "\n".join(out) + "\n" - - -@dataclass(frozen=True) -class Package: - """A source// directory the changelog tool can manage. - - A package is "managed" if it has both a ``config/extension.toml`` (the - version file the compiler bumps) and a ``docs/CHANGELOG.rst`` (the - file the compiler updates). :meth:`discover` returns only managed - packages; instances created directly may not be managed (use - :attr:`is_managed`). + A flush-left line after a bullet ends the RST list and splits the entry. """ - - root: Path - - @property - def name(self) -> str: - return self.root.name - - @property - def changelog_path(self) -> Path: - return self.root / "docs" / "CHANGELOG.rst" - - @property - def toml_path(self) -> Path: - return self.root / "pyproject.toml" - - @property - def default_fragment_dir(self) -> Path: - return self.root / "changelog.d" - - @property - def is_managed(self) -> bool: - return self.toml_path.is_file() and self.changelog_path.is_file() - - def current_version(self) -> Version: - in_project = False - for line in self.toml_path.read_text(encoding="utf-8").splitlines(): - if re.match(r"^\[project\]", line): - in_project = True - elif re.match(r"^\[", line): - in_project = False - if in_project: - m = re.match(r'^version\s*=\s*"([^"]+)"', line) - if m: - return Version(m.group(1)) - raise ValueError(f"{self.name}: no version field found under [project] in {self.toml_path}") - - def write_changelog_entry(self, entry: str, *, dry_run: bool) -> None: - text = self.changelog_path.read_text(encoding="utf-8") - # Self-heal a header that lacks the trailing blank line. The compile - # regex needs ``Changelog\n---+\n\n`` as an anchor; a contributor who - # ships ``Changelog\n---+\n`` (the isaaclab_ppisp shape PR #5748 - # introduced) would otherwise wedge the nightly. Insert the missing - # ``\n`` in-memory and write it back so the on-disk file ends up - # canonical on first compile. No-op when the blank line is already - # there (negative lookahead). - text = re.sub(r"^(Changelog\n-+)\n(?!\n)", r"\1\n\n", text, count=1, flags=re.MULTILINE) - m = CHANGELOG_HEADER_RE.search(text) - if not m: - raise ValueError(f"Could not locate changelog header in {self.changelog_path}") - updated = text[: m.end()] + entry + "\n" + text[m.end() :] - if dry_run: - print(f"\n{'=' * 60}") - print(f"DRY RUN — would write to {_display_path(self.changelog_path)}") - print(f"{'=' * 60}") - print(entry) - else: - self.changelog_path.write_text(updated, encoding="utf-8") - - def write_version(self, new_version: Version, *, dry_run: bool) -> None: - text = self.toml_path.read_text(encoding="utf-8") - in_project = False - new_lines = [] - for line in text.splitlines(keepends=True): - if re.match(r"^\[project\]", line): - in_project = True - elif re.match(r"^\[", line): - in_project = False - if in_project and re.match(r'^version\s*=\s*"[^"]+"', line): - line = re.sub(r'^(version\s*=\s*)"[^"]+"', f'\\1"{new_version}"', line) - new_lines.append(line) - if dry_run: - print(f'DRY RUN — would set version = "{new_version}" in {_display_path(self.toml_path)}') - else: - self.toml_path.write_text("".join(new_lines), encoding="utf-8") - - @classmethod - def from_name(cls, name: str, packages_root: Path = PACKAGES_ROOT) -> Package: - return cls(packages_root / name) - - @classmethod - def discover(cls, packages_root: Path = PACKAGES_ROOT) -> list[Package]: - """Return all managed packages under ``packages_root``, sorted by name.""" - if not packages_root.is_dir(): - return [] - return sorted( - (cls(child) for child in packages_root.iterdir() if child.is_dir() and cls(child).is_managed), - key=lambda p: p.name, - ) - - def compile( - self, - *, - fragments_dir: Path | None = None, - explicit_version: Version | None = None, - dry_run: bool = False, - ) -> bool: - """Compile fragments for this package. Returns True if any were compiled. - - There are exactly two modes: ``dry_run=True`` previews and writes - nothing; ``dry_run=False`` writes the new entry, bumps the version, - **and** deletes the consumed fragments. There is deliberately no - third "write but keep fragments" mode — leaving fragments in place - after a real compile is a footgun (the next compile would re-emit - them as a duplicate version block). - - Args: - fragments_dir: Read fragments from here instead of - :attr:`default_fragment_dir`. Useful for previewing against - example fixtures. - explicit_version: Pin the new version to this string (skips the - per-fragment bump inference). - dry_run: Preview only — no files are written or deleted. - """ - batch = FragmentBatch.from_dir(self._resolve_fragments_dir(fragments_dir)) - - for p in batch.invalid: - print( - f" WARNING: {_display_path(p)} does not match any recognised fragment " - "pattern (.rst, .minor.rst, .major.rst, .skip) — skipping.", - file=sys.stderr, - ) - - if not batch.valid: - if batch.skip_paths: - n = len(batch.skip_paths) - if dry_run: - print(f" {self.name}: would clean {n} stale skip file(s).") - else: - batch.delete_skips() - print(f" {self.name}: cleaned {n} stale skip file(s).") - else: - print(f" {self.name}: no fragments, skipping.") - return False - - # Apply the same content-validation rules the PR gate uses, so a - # malformed fragment that somehow reached this package (e.g. a - # stale fragment that predates a content-rule tightening, or a - # locally-edited file) doesn't silently produce a half-empty - # version block. Runs every fragment that survived filename - # validation in ``from_dir``. - validation_errors = [(f, err) for f in batch.valid if (err := f.validate()) is not None] - if validation_errors: - for f, err in validation_errors: - print(f" ERROR: {_display_path(f.path)}: {err}", file=sys.stderr) - raise ValueError( - f"{self.name}: {len(validation_errors)} fragment(s) failed content validation; " - "fix or remove them before compiling." - ) - - parsed_pairs = batch.parsed() - if not parsed_pairs: - print(f" {self.name}: all fragments empty after parsing, skipping.") - return False - - new_version, bump_label, entry = batch.compile_to_entry( - self.current_version(), explicit_version=explicit_version - ) - print(f" {self.name}: {len(parsed_pairs)} fragment(s) → version {new_version}{bump_label}") - - if not self.changelog_path.exists(): - # Should never happen with managed packages discovered via - # ``Package.discover()`` — defensive check for callers that - # construct a ``Package`` directly with an unmanaged root. - raise ValueError( - f"{_display_path(self.changelog_path)} does not exist; " - f"package {self.name!r} is not managed (missing CHANGELOG.rst)." + if not re.search(r"^\s*\*", text, re.MULTILINE): + return "has no ``* `` bullet" + if re.search(r"^(?!\*|\s|$)", text, re.MULTILINE): + return "has a line that is neither a ``* `` bullet nor an indented continuation" + return None + + +def check_fragment(path: Path) -> list[str]: + """Return the errors of one added fragment, named relative to the repository.""" + rel = path.relative_to(REPO_ROOT) + if MARKER_RE.match(path.name): + return [] + if not ENTRY_RE.match(path.name): + return [ + f"{rel}: expected .<{'|'.join(TYPES)}>.rst holding * bullets, or an empty .skip," + " .minor or .major; see skills/developer/changelog-fragments/SKILL.md" + ] + error = check_bullet_list(path.read_text(encoding="utf-8")) + return [f"{rel}: {error}"] if error else [] + + +def check_changed_packages(changed: set[str], added: set[str]) -> list[str]: + """Return the fragment errors for a branch that changed, and newly added, the given repo paths.""" + errors = [] + for pkg in packages(): + rel = pkg.relative_to(REPO_ROOT).as_posix() + fragment_dir = f"{rel}/changelog.d/" + touched = {f for f in changed if f.startswith(fragment_dir) and not f.endswith("/.gitkeep")} + # Pending fragments are immutable: an edit could rewrite another PR's entry. + errors += [f"{f}: changes a pending fragment; add a new one instead" for f in sorted(touched - added)] + fragments = sorted(touched & added) + for fragment in fragments: + errors += check_fragment(REPO_ROOT / fragment) + entries = [f for f in fragments if not f.endswith((".minor", ".major"))] + if not entries and any(f.startswith(f"{rel}/") and not f.startswith(fragment_dir) for f in changed): + errors.append( + f"{rel}: changed without a changelog fragment; add {fragment_dir}..rst or .skip" ) - self.write_changelog_entry(entry, dry_run=dry_run) - self.write_version(new_version, dry_run=dry_run) - - if not dry_run: - n_frag, n_skip = batch.delete_all() - msg = f" {self.name}: deleted {n_frag} fragment(s)" - if n_skip: - msg += f" and {n_skip} skip file(s)" - print(msg + ".") - - return True + return errors - def _resolve_fragments_dir(self, override: Path | None) -> Path: - """Pick the directory ``compile`` should read fragments from. - ``None`` means "use this package's own ``changelog.d/``"; an - absolute path is used as-is; a relative path is resolved against - ``REPO_ROOT`` so callers can pass things like - ``tools/changelog/test/integration/01_patch_bump/fragments`` without - worrying about the cwd. - """ - if override is None: - return self.default_fragment_dir - return override if override.is_absolute() else (REPO_ROOT / override).resolve() +def check_markers() -> list[str]: + """Return an error for each ``CHANGELOG.rst`` missing the line towncrier writes new entries after.""" + return [ + f"{pkg.relative_to(REPO_ROOT)}/docs/CHANGELOG.rst: missing the {RELEASE_NOTES_MARKER!r} line" + for pkg in packages() + # towncrier matches the whole line; otherwise it writes the release above the file header. + if f"{RELEASE_NOTES_MARKER}\n" not in (pkg / "docs/CHANGELOG.rst").read_text(encoding="utf-8") + ] -@dataclass(frozen=True) -class PRDiff: - """A snapshot of "what this PR changed against its base branch." +def check_lock_pins() -> list[str]: + """Return an error for each local package missing from ``uv.lock`` or pinned there at a stale version. - Wraps two views from the same git diff: ``changed`` (any file modified - or added) and ``added`` (the strict subset that's new on this branch). - Tests construct ``PRDiff`` directly with synthetic sets; - :meth:`from_git` runs the real ``git diff`` for production use. + Catches a PR that adds a package, edits a version or takes its side of a ``uv.lock`` conflict without + re-locking, which would make the next ``uv run`` rewrite ``uv.lock``. Only local packages are compared; + the rest of ``uv.lock`` is not resolved. """ - - changed: set[str] - added: set[str] - - @classmethod - def from_git(cls, base_ref: str, *, include_worktree: bool = False) -> PRDiff: - """Collect the branch diff against ``origin/``. - - Args: - base_ref: Base branch name. - include_worktree: Whether to include staged and unstaged tracked - changes in addition to committed branch changes. - """ - - remote_base = f"origin/{base_ref}" - if include_worktree: - merge_base = subprocess.run( - ["git", "merge-base", remote_base, "HEAD"], - capture_output=True, - text=True, - check=True, - cwd=REPO_ROOT, - ).stdout.strip() - diff_target = merge_base - else: - diff_target = f"{remote_base}...HEAD" - - def _diff(extra_args: list[str]) -> set[str]: - result = subprocess.run( - ["git", "diff", "--name-only", *extra_args, diff_target], - capture_output=True, - text=True, - check=True, - cwd=REPO_ROOT, + lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + locked = { + package["source"]["editable"]: package for package in lock["package"] if "editable" in package.get("source", {}) + } + root = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + sources = root.get("tool", {}).get("uv", {}).get("sources", {}).values() + errors = [ + f"uv.lock has no entry for {source['path']}; run `uv lock`" + for source in sources + if isinstance(source, dict) and source.get("editable") and source["path"] not in locked + ] + for path, package in locked.items(): + project = tomllib.loads((REPO_ROOT / path / "pyproject.toml").read_text(encoding="utf-8"))["project"] + if project.get("version") != package["version"]: + errors.append( + f"uv.lock pins {package['name']} {package['version']}, but {path}/pyproject.toml is" + f" {project.get('version')}; run `uv lock`" ) - return {f for f in result.stdout.splitlines() if f} - - changed = _diff([]) - added = _diff(["--diff-filter=A"]) - return cls(changed=changed, added=added) - - def evaluate( - self, - packages: list[Package], - ) -> tuple[list[str], list[tuple[str, str]]]: - """Apply the PR-gate rules and return ``(missing_packages, invalid_fragments)``. - - Rules: - - 1. **Immutability** — every fragment file in the diff must be in - ``added`` (added on this branch). Modifying or renaming an existing - fragment is rejected with a hint to add a new one instead. - - 2. **Content validity** — every added ``*.rst`` fragment must parse - (recognised section headings + at least one bullet). ``.skip`` and - ``.gitkeep`` are exempt. - - 3. **Slug uniqueness** — within a package's ``changelog.d/``, no two - fragments may share the same slug. If an added fragment's slug - collides with an existing or co-added fragment, fail with a hint - to rename (e.g. append ``-2``). - - 4. **Required fragment per touched package** — for each managed - package the PR touches in ``source/`` (outside ``changelog.d/``), - the PR must *add* at least one valid fragment to that package's - ``changelog.d/``. Chained PRs (parent PR's fragment shows up in - the child's diff) naturally satisfy this — slug uniqueness is - the only constraint that matters. - """ - missing: list[str] = [] - invalid_fragments: list[tuple[str, str]] = [] + return errors - for pkg in packages: - pkg_prefix = f"source/{pkg.name}/" - changelog_dir = f"source/{pkg.name}/changelog.d/" - source_changed = [f for f in self.changed if f.startswith(pkg_prefix) and not f.startswith(changelog_dir)] - fragment_changes = [f for f in self.changed if f.startswith(changelog_dir)] +def cmd_check(args: argparse.Namespace) -> int: + """PR gate: check the fragments added since the merge base, the release-notes markers and the lock pins.""" + base = run("git", "merge-base", f"origin/{args.base_ref}", "HEAD").strip() + target = [] if args.include_worktree else ["HEAD"] + changed = run("git", "diff", "--name-only", "--no-renames", base, *target).splitlines() + added = run("git", "diff", "--name-only", "--no-renames", "--diff-filter=A", base, *target).splitlines() + errors = check_changed_packages(set(changed), set(added)) + check_markers() + check_lock_pins() + for error in errors: + print(f"::error::{error}") + return int(bool(errors)) - # Map *pre-existing* fragments in the package's changelog.d/ by slug, - # for the uniqueness check below. The CI checkout contains both - # base-branch fragments and the PR's additions side by side, so we - # must explicitly exclude added files — otherwise an added file can - # overwrite the entry for a colliding pre-existing fragment with - # the same slug, hiding the very collision we're trying to detect. - # Skip ``.gitkeep`` and unrecognised filenames — they can't collide. - added_basenames = {Path(f).name for f in self.added if f.startswith(changelog_dir)} - existing_slugs: dict[str, str] = {} - existing_dir = pkg.default_fragment_dir - if existing_dir.is_dir(): - for p in existing_dir.iterdir(): - if p.is_dir() or p.name == ".gitkeep" or p.name in added_basenames: - continue - slug = Fragment.parse_slug(p.name) - if slug is not None: - existing_slugs[slug] = p.name - added_slugs: dict[str, str] = {} - for f in fragment_changes: - path = Path(f) - if path.name == ".gitkeep": - continue +# --------------------------------------------------------------------------------------------------- +# compile: the nightly +# --------------------------------------------------------------------------------------------------- - # Rule 1: immutability — modifying an existing fragment is forbidden. - if f not in self.added: - invalid_fragments.append( - ( - f, - "fragments are immutable — add a new fragment with a different slug " - "instead of editing an existing one", - ) - ) - continue - # Rule 2: content validity (only for *.rst, not *.skip). - if not SKIP_RE.match(path.name): - err = Fragment(REPO_ROOT / f).validate() - if err: - invalid_fragments.append((f, err)) - continue +def outputs(pkg: Path) -> list[Path]: + """Return the paths ``compile`` may change in ``pkg``.""" + return [pkg / "pyproject.toml", pkg / "docs/CHANGELOG.rst", pkg / "changelog.d"] - # Rule 3: slug uniqueness within the package's changelog.d/. - slug = Fragment.parse_slug(path.name) - if slug is None: - # Filename validation already flagged this above for *.rst, - # but a malformed *.skip would slip through. Surface it. - invalid_fragments.append( - (f, "invalid filename — must be .rst, .minor.rst, .major.rst, or .skip") - ) - continue - if slug in existing_slugs and existing_slugs[slug] != path.name: - invalid_fragments.append( - ( - f, - f"slug {slug!r} collides with existing fragment " - f"{existing_slugs[slug]!r} — rename to {slug}-2 (or any unused slug)", - ) - ) - continue - if slug in added_slugs and added_slugs[slug] != path.name: - invalid_fragments.append( - ( - f, - f"slug {slug!r} collides with another added fragment " - f"{added_slugs[slug]!r} — rename one to {slug}-2 (or any unused slug)", - ) - ) - continue - added_slugs[slug] = path.name - if not source_changed: - continue +def restore(pkg: Path) -> None: + """Roll ``pkg``'s compile outputs back to ``HEAD``.""" + run("git", "checkout", "HEAD", "--", *outputs(pkg)) + run("git", "clean", "-fdq", "--", pkg / "changelog.d") - # Rule 4: this PR must add at least one valid fragment for the package. - owned = [ - f - for f in fragment_changes - if f in self.added and (FRAGMENT_RE.match(Path(f).name) or SKIP_RE.match(Path(f).name)) - ] - if not owned: - missing.append(pkg.name) - return missing, invalid_fragments +def compile_package(pkg: Path) -> str | None: + """Build ``pkg``'s pending fragments into its CHANGELOG.rst and bump its version. - -# --------------------------------------------------------------------------- -# Subcommand handlers -# --------------------------------------------------------------------------- - - -def cmd_compile(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: - if args.fragments_dir is not None and args.all: - parser.error("--fragments-dir requires --package (it cannot apply to all packages at once)") - if args.version is not None and args.all: - parser.error( - "--version requires --package (each managed package has its own version trajectory; " - "pin one with --package )" - ) - # Validate ``--version`` shape up front so a typo like ``--version 4.7`` - # fails at argument parsing instead of silently writing ``4.7`` into - # ``CHANGELOG.rst`` and ``pyproject.toml``. - explicit_version: Version | None = None - if args.version is not None: - try: - explicit_version = Version(args.version) - except ValueError as e: - parser.error(f"--version: {e}") - - if args.package: - pkg = Package.from_name(args.package) - if not pkg.root.is_dir(): - parser.error(f"--package {args.package!r}: directory not found at {pkg.root}") - if not pkg.is_managed: - parser.error( - f"--package {args.package!r} is not managed: missing pyproject.toml or " - f"docs/CHANGELOG.rst at {pkg.root}. Run with --all to see the discovered list." - ) - packages = [pkg] - else: - packages = Package.discover() - - # Per-package isolation: one package's failure must not abort the batch. - # The nightly workflow commits and pushes whatever compiled successfully, - # so a malformed file in one package only loses that package's release - # notes for this cycle — the rest still ships. - any_compiled = False - failures: list[tuple[str, str]] = [] - for pkg in packages: - try: - compiled = pkg.compile( - fragments_dir=args.fragments_dir, - explicit_version=explicit_version, - dry_run=args.dry_run, - ) - except (FileNotFoundError, ValueError) as e: - print(f" ERROR ({pkg.name}): {e}", file=sys.stderr) - failures.append((pkg.name, str(e))) - continue - any_compiled = any_compiled or compiled - - if failures: - print(file=sys.stderr) - print(f"::error::{len(failures)} package(s) failed to compile:", file=sys.stderr) - for name, reason in failures: - print(f" • {name}: {reason}", file=sys.stderr) + Returns: + The new version, or ``None`` if the package had no entries to release. + """ + fragments = pkg / "changelog.d" + if not fragments.is_dir(): + return None + split_legacy_fragments(fragments) # WAR + names = [path.name for path in fragments.iterdir()] + # No entries means no release: drop the .skip and tier files, so a stale tier cannot bump a later release. + if not any(ENTRY_RE.match(name) for name in names): + for name in filter(MARKER_RE.match, names): + (fragments / name).unlink() + return None + bump = next((tier for tier in ("major", "minor") if any(n.endswith(f".{tier}") for n in names)), "patch") + version = run("uv", "version", "--project", pkg, "--bump", bump, "--frozen", "--short").strip() + run(*TOWNCRIER, "build", "--yes", "--config", CONFIG, "--dir", pkg, "--version", version) + return version + + +def without_local_versions(lock_text: str) -> dict: + """Return a parsed ``uv.lock`` without the versions of the local (editable) packages.""" + lock = tomllib.loads(lock_text) + for package in lock["package"]: + if "editable" in package.get("source", {}): + package.pop("version", None) + return lock + + +def sync_lock() -> None: + """Re-lock ``uv.lock`` for the bumped versions; raise if anything else changed.""" + before = (REPO_ROOT / "uv.lock").read_text(encoding="utf-8") + # Under UV_FROZEN, which developer shells may export, `uv lock` only validates and keeps the old pins. + run("uv", "lock", env={k: v for k, v in os.environ.items() if k != "UV_FROZEN"}) + after = (REPO_ROOT / "uv.lock").read_text(encoding="utf-8") + diff = run("git", "diff", "--no-color", "-U0", "--", "uv.lock") + moved = [line for line in diff.splitlines() if line[:1] in "+-" and not line.startswith(("+++", "---"))] + # Only version lines may move, which rejects a uv release that re-serializes the file, and only local + # packages' versions may change, which rejects a moved dependency pin. + if any(not line[1:].startswith("version = ") for line in moved) or ( + without_local_versions(before) != without_local_versions(after) + ): + raise RuntimeError("uv lock changed more than package versions") + + +def cmd_compile(args: argparse.Namespace) -> int: + """Nightly: bump and build every package with pending entries, re-lock ``uv.lock`` and stage the outputs. + + Returns 1 if anything failed. A failed package is rolled back while the others stay staged; a failed + re-lock rolls everything back and stages nothing. + """ + # Rolling back restores HEAD, so start clean to never discard local work. + if run("git", "status", "--porcelain"): + print("::error::compile needs a clean checkout; commit or stash local changes first", file=sys.stderr) return 1 - - if not any_compiled: - print("No fragments found in any package.") - return 0 - - -def cmd_check(args: argparse.Namespace, _parser: argparse.ArgumentParser) -> int: + failed = False + for pkg in packages(): + try: + version = compile_package(pkg) + except (subprocess.CalledProcessError, ValueError) as e: + # Per-package isolation: roll this package back; the nightly commits the others. + restore(pkg) + print(f"::error::{pkg.name}: {getattr(e, 'stderr', None) or e}", file=sys.stderr) + failed = True + else: + if version: + print(f"{pkg.name} -> {version}") try: - diff = PRDiff.from_git(args.base_ref, include_worktree=args.include_worktree) - except subprocess.CalledProcessError as e: - print(f"ERROR: git diff failed: {e.stderr}", file=sys.stderr) - return 1 - - packages = Package.discover() - - # Header invariant — every managed package's ``CHANGELOG.rst`` must - # contain a parseable header. ``write_changelog_entry`` self-heals the - # narrow "missing trailing blank line" case, but every other broken - # shape (no ``Changelog`` header, no underline, wrong underline char, - # leading whitespace, etc.) still raises at compile time and would - # wedge the next nightly. Block those at PR time with a clear error. - malformed_headers: list[str] = [] - for pkg in packages: - text = pkg.changelog_path.read_text(encoding="utf-8") - # Apply the same normalization compile would apply, then check. - text = re.sub(r"^(Changelog\n-+)\n(?!\n)", r"\1\n\n", text, count=1, flags=re.MULTILINE) - if CHANGELOG_HEADER_RE.search(text) is None: - malformed_headers.append(str(pkg.changelog_path.relative_to(REPO_ROOT))) - - missing, invalid_fragments = diff.evaluate(packages) - - if invalid_fragments: - print("::error::Invalid changelog fragment(s) in this PR:") - for path, reason in invalid_fragments: - print(f" • {path}") - print(f" → {reason}") - print() - - if missing: - print("::error::Missing changelog fragments for the following packages:") - for pkg_name in missing: - print(f" • {pkg_name}") - print(f" → add source/{pkg_name}/changelog.d/.rst (patch bump)") - print(f" → or source/{pkg_name}/changelog.d/.minor.rst (minor bump)") - print(f" → or source/{pkg_name}/changelog.d/.major.rst (major bump)") - print(f" → or source/{pkg_name}/changelog.d/.skip (no entry, no bump)") - print() - print("Slug = your branch name with `/` replaced by `-` (or any short, unique name).") - print() - print("Fragment format (source//changelog.d/[.minor|.major].rst):") - print() - print(" Added") - print(" ^^^^^") - print() - print(" * Added :class:`~pkg.Foo` for feature X.") - print() - print(" Fixed") - print(" ^^^^^") - print() - print(" * Fixed edge case in :meth:`~pkg.Foo.bar`.") - print() - print("See AGENTS.md ## Changelog for full guidance.") - - if malformed_headers: - print("::error::Malformed CHANGELOG.rst — header must contain ``Changelog\\n---------\\n\\n``") - print("(header line, underline, then a blank line — the anchor the nightly compile prepends to):") - for path in malformed_headers: - print(f" • {path}") - print() - print("Seed the file with at minimum:") - print() - print(" Changelog") - print(" ---------") - print() - print(" 0.1.0 (YYYY-MM-DD)") - print(" ~~~~~~~~~~~~~~~~~~") - print() - print(" Added") - print(" ^^^^^") - print() - print(" * Initial release.") - print() - - if invalid_fragments or missing or malformed_headers: + sync_lock() + except (subprocess.CalledProcessError, RuntimeError) as e: + # Versions must not move without their lock pins: abort, leaving the checkout at HEAD. + run("git", "checkout", "HEAD", "--", "uv.lock") + for pkg in packages(): + restore(pkg) + print(f"::error::uv.lock: {getattr(e, 'stderr', None) or e}", file=sys.stderr) return 1 + # Stage exactly the compile outputs; anything else changed in the checkout is reported, not committed. + run("git", "add", "-A", "--", "uv.lock", *(path for pkg in packages() for path in outputs(pkg) if path.exists())) + unexpected = run("git", "ls-files", "--modified", "--others", "--exclude-standard").splitlines() + if unexpected: + print("::error::compile changed files outside its outputs:\n" + "\n".join(unexpected), file=sys.stderr) + failed = True + return int(failed) - print("✓ All modified packages have valid changelog fragments.") - return 0 - - -# --------------------------------------------------------------------------- -# CLI entry point -# --------------------------------------------------------------------------- - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - # The module docstring carries the full usage walkthrough — surfacing - # it as the parser description means ``cli.py --help`` shows the same - # guidance someone reading the source would see. - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - sub = parser.add_subparsers(dest="cmd", required=True, metavar="{compile,check}") - - p_compile = sub.add_parser( - "compile", - help="Compile fragments into CHANGELOG.rst (maintainer release-time tool).", - description="Compile accumulated fragments into per-package CHANGELOG.rst entries and bump extension.toml.", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - p_compile.set_defaults(func=cmd_compile) - - # ── Target: which packages to compile (required, mutually exclusive) ── - target = p_compile.add_argument_group("target", "Which package(s) to compile (required, mutually exclusive)") - target_group = target.add_mutually_exclusive_group(required=True) - target_group.add_argument("--package", metavar="NAME", help="Compile a single package.") - target_group.add_argument("--all", action="store_true", help="Compile all managed packages.") - - # ── Version source: by default inferred from filename suffixes ──────── - version_group = p_compile.add_argument_group( - "version (optional)", - "By default the new version is inferred from the filename suffixes of the consumed fragments.", - ) - version_group.add_argument( - "--version", - metavar="X.Y.Z", - help=( - "Pin the package to an explicit version, skipping the per-fragment bump inference. " - "Requires --package — each managed package has its own version trajectory and " - "applying a single version to all of them would corrupt their independent histories." - ), - ) - - # ── Execution mode: preview vs apply, where to read fragments from ──── - exec_group = p_compile.add_argument_group("execution") - exec_group.add_argument( - "--dry-run", - action="store_true", - help=( - "Preview only — no files are written or deleted. Without this flag, " - "the compile writes the new entry, bumps the version, and deletes " - "the consumed fragments." - ), - ) - exec_group.add_argument( - "--fragments-dir", - type=Path, - default=None, - metavar="PATH", - help=( - "Override the directory to read fragments from " - "(default: source//changelog.d/). " - "Useful for previewing against example fragments without touching real ones. " - "Only valid with --package." - ), - ) - - p_check = sub.add_parser( - "check", - help="Verify each modified package has a valid fragment (PR gate).", - description="Verify each modified package has a valid changelog fragment.", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - p_check.set_defaults(func=cmd_check) - p_check.add_argument( - "base_ref", - nargs="?", - default=os.environ.get("ISAACLAB_CHANGELOG_BASE_REF", "develop"), - help=( - "Base branch to diff against (e.g. 'main' or 'develop'). " - "Defaults to ISAACLAB_CHANGELOG_BASE_REF or 'develop'." - ), - ) - p_check.add_argument( - "--include-worktree", - action="store_true", - help=("Include staged and unstaged tracked changes in the branch diff. Used by the local pre-commit hook."), - ) - return parser +# --------------------------------------------------------------------------------------------------- +# entry point +# --------------------------------------------------------------------------------------------------- def main() -> None: - parser = _build_parser() + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="cmd", required=True) + check = sub.add_parser("check", help="PR gate: every changed package adds a valid fragment.") + check.add_argument("base_ref", nargs="?", default=os.environ.get("ISAACLAB_CHANGELOG_BASE_REF", "develop")) + check.add_argument("--include-worktree", action="store_true", help="Include uncommitted tracked changes.") + check.set_defaults(func=cmd_check) + compile_parser = sub.add_parser("compile", help="Bump versions, build CHANGELOG.rst entries and re-lock.") + compile_parser.set_defaults(func=cmd_compile) args = parser.parse_args() - sys.exit(args.func(args, parser)) + sys.exit(args.func(args)) if __name__ == "__main__": diff --git a/tools/changelog/test/integration/01_patch_bump/changelog_after.rst b/tools/changelog/test/integration/01_patch_bump/changelog_after.rst deleted file mode 100644 index 199771149d62..000000000000 --- a/tools/changelog/test/integration/01_patch_bump/changelog_after.rst +++ /dev/null @@ -1,25 +0,0 @@ -Changelog ---------- - -1.2.4 (2026-04-30) -~~~~~~~~~~~~~~~~~~ - -Changed -^^^^^^^ - -* Tightened error message in :class:`~example.Foo` when a required argument is missing. - -Fixed -^^^^^ - -* Fixed missing GPU sync in :func:`~example.refresh_buffers` that occasionally returned stale data. -* Fixed off-by-one in :meth:`~example.Foo.bar` when the input list was empty. - - -1.2.3 (2026-01-15) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/01_patch_bump/changelog_before.rst b/tools/changelog/test/integration/01_patch_bump/changelog_before.rst deleted file mode 100644 index 81dc30f240c3..000000000000 --- a/tools/changelog/test/integration/01_patch_bump/changelog_before.rst +++ /dev/null @@ -1,10 +0,0 @@ -Changelog ---------- - -1.2.3 (2026-01-15) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/01_patch_bump/fragments/asmith-fix-collision-margin.rst b/tools/changelog/test/integration/01_patch_bump/fragments/asmith-fix-collision-margin.rst deleted file mode 100644 index 2f4c9e39a432..000000000000 --- a/tools/changelog/test/integration/01_patch_bump/fragments/asmith-fix-collision-margin.rst +++ /dev/null @@ -1,9 +0,0 @@ -Fixed -^^^^^ - -* Fixed missing GPU sync in :func:`~example.refresh_buffers` that occasionally returned stale data. - -Changed -^^^^^^^ - -* Tightened error message in :class:`~example.Foo` when a required argument is missing. diff --git a/tools/changelog/test/integration/01_patch_bump/fragments/jdoe-fix-mass-units.rst b/tools/changelog/test/integration/01_patch_bump/fragments/jdoe-fix-mass-units.rst deleted file mode 100644 index f3a7a66a215a..000000000000 --- a/tools/changelog/test/integration/01_patch_bump/fragments/jdoe-fix-mass-units.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed off-by-one in :meth:`~example.Foo.bar` when the input list was empty. diff --git a/tools/changelog/test/integration/02_minor_bump/changelog_after.rst b/tools/changelog/test/integration/02_minor_bump/changelog_after.rst deleted file mode 100644 index bc5a3fee0e35..000000000000 --- a/tools/changelog/test/integration/02_minor_bump/changelog_after.rst +++ /dev/null @@ -1,30 +0,0 @@ -Changelog ---------- - -1.3.0 (2026-04-30) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.NewSensor` for IMU-based proprioception. -* Added :func:`~example.helper` utility for batched coordinate transforms. - -Changed -^^^^^^^ - -* Documented thread-safety guarantees for :class:`~example.Worker`. - -Fixed -^^^^^ - -* Fixed a NaN propagation in :meth:`~example.Sensor.update`. - - -1.2.3 (2026-01-15) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/02_minor_bump/changelog_before.rst b/tools/changelog/test/integration/02_minor_bump/changelog_before.rst deleted file mode 100644 index 81dc30f240c3..000000000000 --- a/tools/changelog/test/integration/02_minor_bump/changelog_before.rst +++ /dev/null @@ -1,10 +0,0 @@ -Changelog ---------- - -1.2.3 (2026-01-15) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/02_minor_bump/fragments/asmith-add-multi-asset-spawner.minor.rst b/tools/changelog/test/integration/02_minor_bump/fragments/asmith-add-multi-asset-spawner.minor.rst deleted file mode 100644 index 5f623cf02ad5..000000000000 --- a/tools/changelog/test/integration/02_minor_bump/fragments/asmith-add-multi-asset-spawner.minor.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added :class:`~example.NewSensor` for IMU-based proprioception. diff --git a/tools/changelog/test/integration/02_minor_bump/fragments/blee-add-camera-output-contract.minor.rst b/tools/changelog/test/integration/02_minor_bump/fragments/blee-add-camera-output-contract.minor.rst deleted file mode 100644 index a3feda328623..000000000000 --- a/tools/changelog/test/integration/02_minor_bump/fragments/blee-add-camera-output-contract.minor.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added :func:`~example.helper` utility for batched coordinate transforms. - -Changed -^^^^^^^ - -* Documented thread-safety guarantees for :class:`~example.Worker`. diff --git a/tools/changelog/test/integration/02_minor_bump/fragments/jdoe-fix-rotation-frame.rst b/tools/changelog/test/integration/02_minor_bump/fragments/jdoe-fix-rotation-frame.rst deleted file mode 100644 index e103861d0d98..000000000000 --- a/tools/changelog/test/integration/02_minor_bump/fragments/jdoe-fix-rotation-frame.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed a NaN propagation in :meth:`~example.Sensor.update`. diff --git a/tools/changelog/test/integration/03_major_bump/changelog_after.rst b/tools/changelog/test/integration/03_major_bump/changelog_after.rst deleted file mode 100644 index cec9e3221263..000000000000 --- a/tools/changelog/test/integration/03_major_bump/changelog_after.rst +++ /dev/null @@ -1,34 +0,0 @@ -Changelog ---------- - -2.0.0 (2026-04-30) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.AnotherSensor` for proximity detection. - -Changed -^^^^^^^ - -* **Breaking:** :meth:`~example.Foo.bar` now returns a tuple ``(value, error)`` instead of raising. - -Removed -^^^^^^^ - -* Removed deprecated module ``example.old_api`` (use :mod:`~example.api` instead). - -Fixed -^^^^^ - -* Fixed a deadlock in :class:`~example.Pool` under high concurrency. - - -1.2.3 (2026-01-15) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/03_major_bump/changelog_before.rst b/tools/changelog/test/integration/03_major_bump/changelog_before.rst deleted file mode 100644 index 81dc30f240c3..000000000000 --- a/tools/changelog/test/integration/03_major_bump/changelog_before.rst +++ /dev/null @@ -1,10 +0,0 @@ -Changelog ---------- - -1.2.3 (2026-01-15) -~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/03_major_bump/fragments/asmith-add-warp-contact-stream.minor.rst b/tools/changelog/test/integration/03_major_bump/fragments/asmith-add-warp-contact-stream.minor.rst deleted file mode 100644 index 864d48ce0cef..000000000000 --- a/tools/changelog/test/integration/03_major_bump/fragments/asmith-add-warp-contact-stream.minor.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added :class:`~example.AnotherSensor` for proximity detection. diff --git a/tools/changelog/test/integration/03_major_bump/fragments/blee-rename-articulation-api.major.rst b/tools/changelog/test/integration/03_major_bump/fragments/blee-rename-articulation-api.major.rst deleted file mode 100644 index d392bcd339c2..000000000000 --- a/tools/changelog/test/integration/03_major_bump/fragments/blee-rename-articulation-api.major.rst +++ /dev/null @@ -1,9 +0,0 @@ -Removed -^^^^^^^ - -* Removed deprecated module ``example.old_api`` (use :mod:`~example.api` instead). - -Changed -^^^^^^^ - -* **Breaking:** :meth:`~example.Foo.bar` now returns a tuple ``(value, error)`` instead of raising. diff --git a/tools/changelog/test/integration/03_major_bump/fragments/jdoe-fix-articulation-state.rst b/tools/changelog/test/integration/03_major_bump/fragments/jdoe-fix-articulation-state.rst deleted file mode 100644 index b184ab45c6e2..000000000000 --- a/tools/changelog/test/integration/03_major_bump/fragments/jdoe-fix-articulation-state.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed a deadlock in :class:`~example.Pool` under high concurrency. diff --git a/tools/changelog/test/integration/README.md b/tools/changelog/test/integration/README.md deleted file mode 100644 index b0584dc6f314..000000000000 --- a/tools/changelog/test/integration/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Changelog integration fixtures - -End-to-end test fixtures for `tools/changelog/cli.py compile`. Each -subdirectory holds a worked example: input fragments, the starting -`CHANGELOG.rst`, and the expected compiled output. - -`tools/changelog/test/test_integration.py` runs the compiler -against each one and asserts the output matches `changelog_after.rst`. -The fixtures double as human-readable demos — read alongside the PR -description to see how the system handles patch / minor / major bumps -and cross-fragment section merges. - -## Layout - -| Demo | Fragments | Bump | Resulting version | -|---|---|---|---| -| `01_patch_bump/` | 2 × `.rst` | patch | `1.2.3 → 1.2.4` | -| `02_minor_bump/` | 1 × `.rst` + 2 × `.minor.rst` | minor | `1.2.3 → 1.3.0` | -| `03_major_bump/` | 1 × `.rst` + 1 × `.minor.rst` + 1 × `.major.rst` | major | `1.2.3 → 2.0.0` | - -Each demo includes a `changelog_before.rst` (initial state) and a -`changelog_after.rst` (expected post-compile state). The bump tier is the -**max** of every fragment's filename suffix in the batch. - -## Run the compiler against a demo - -```bash -uv run python tools/changelog/cli.py compile --package isaaclab \ - --fragments-dir tools/changelog/test/integration/02_minor_bump/fragments \ - --dry-run -``` - -`--dry-run` prevents the compile from consuming (deleting) the fixture -fragments. The output should match `02_minor_bump/changelog_after.rst` -modulo today's date. diff --git a/tools/changelog/test/invalid_content/3002.rst b/tools/changelog/test/invalid_content/3002.rst deleted file mode 100644 index 190621a3aaf3..000000000000 --- a/tools/changelog/test/invalid_content/3002.rst +++ /dev/null @@ -1 +0,0 @@ -Just a free-form note with no section headings. diff --git a/tools/changelog/test/invalid_content/3003.rst b/tools/changelog/test/invalid_content/3003.rst deleted file mode 100644 index 4470915bae8e..000000000000 --- a/tools/changelog/test/invalid_content/3003.rst +++ /dev/null @@ -1,2 +0,0 @@ -Added -^^^^^ diff --git a/tools/changelog/test/invalid_content/3004.rst b/tools/changelog/test/invalid_content/3004.rst deleted file mode 100644 index 57aa8df9bb87..000000000000 --- a/tools/changelog/test/invalid_content/3004.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added ``foo()`` to support feature X. - -This is a free-form paragraph that lives at column 0 inside the Added -section, neither a bullet nor a continuation of the previous bullet. -The bot would splice this verbatim into ``CHANGELOG.rst`` and Sphinx -then rejects the build with ``Unexpected indentation [docutils]``. diff --git a/tools/changelog/test/invalid_filenames/1234.notabump.rst b/tools/changelog/test/invalid_filenames/1234.notabump.rst deleted file mode 100644 index f70a86344dc6..000000000000 --- a/tools/changelog/test/invalid_filenames/1234.notabump.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* This file has an unrecognised bump tier and should be rejected. diff --git a/tools/changelog/test/invalid_filenames/multi.dot.slug.rst b/tools/changelog/test/invalid_filenames/multi.dot.slug.rst deleted file mode 100644 index c9ea00434158..000000000000 --- a/tools/changelog/test/invalid_filenames/multi.dot.slug.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* This file's slug contains dots (reserved for the tier suffix) and should be rejected. diff --git a/tools/changelog/test/test_bump_suffix.py b/tools/changelog/test/test_bump_suffix.py deleted file mode 100644 index 1c191c6b33d9..000000000000 --- a/tools/changelog/test/test_bump_suffix.py +++ /dev/null @@ -1,135 +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 - -"""Bump-tier inference: filename suffix → bump, and aggregating across a batch. - -These tests use the worked examples under :file:`tools/changelog/examples/` -as fixtures so the same files double as human-readable demos and as -inputs the test suite verifies. -""" - -from __future__ import annotations - -from pathlib import Path - -import cli -import pytest - -EXAMPLES = Path(__file__).parent / "integration" - - -# --------------------------------------------------------------------------- -# Filename → bump tier (one demo per tier, tested separately) -# --------------------------------------------------------------------------- - - -def test_patch_bump_demo_aggregates_to_patch(): - """``examples/01_patch_bump/`` has two ``.rst`` files (no suffix) → patch.""" - batch = cli.FragmentBatch.from_dir(EXAMPLES / "01_patch_bump" / "fragments") - assert batch.invalid == [] - assert {f.name for f in batch.valid} == { - "jdoe-fix-mass-units.rst", - "asmith-fix-collision-margin.rst", - } - assert all(f.bump == "patch" for f in batch.valid) - assert batch.aggregate_bump() == "patch" - - -def test_minor_bump_demo_aggregates_to_minor(): - """``examples/02_minor_bump/`` mixes patch + minor fragments → minor wins.""" - batch = cli.FragmentBatch.from_dir(EXAMPLES / "02_minor_bump" / "fragments") - assert batch.invalid == [] - assert {f.name for f in batch.valid} == { - "jdoe-fix-rotation-frame.rst", - "asmith-add-multi-asset-spawner.minor.rst", - "blee-add-camera-output-contract.minor.rst", - } - bumps = sorted(f.bump for f in batch.valid) - assert bumps == ["minor", "minor", "patch"] - assert batch.aggregate_bump() == "minor" - - -def test_major_bump_demo_aggregates_to_major(): - """``examples/03_major_bump/`` mixes patch + minor + major → major wins.""" - batch = cli.FragmentBatch.from_dir(EXAMPLES / "03_major_bump" / "fragments") - assert batch.invalid == [] - assert {f.name for f in batch.valid} == { - "jdoe-fix-articulation-state.rst", - "asmith-add-warp-contact-stream.minor.rst", - "blee-rename-articulation-api.major.rst", - } - bumps = sorted(f.bump for f in batch.valid) - assert bumps == ["major", "minor", "patch"] - assert batch.aggregate_bump() == "major" - - -# --------------------------------------------------------------------------- -# Pure aggregation logic (no filesystem) -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "bumps,expected", - [ - ([], "patch"), - (["patch"], "patch"), - (["patch", "patch"], "patch"), - (["patch", "minor"], "minor"), - (["minor", "patch", "minor"], "minor"), - (["patch", "minor", "major"], "major"), - (["major", "patch"], "major"), - ], -) -def test_aggregate_bump_logic(bumps, expected): - assert cli.FragmentBatch._aggregate(bumps) == expected - - -# --------------------------------------------------------------------------- -# Filename regex — what the gate and compiler agree to accept -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "name,is_fragment,is_skip", - [ - ("1234.rst", True, False), - ("1234.minor.rst", True, False), - ("1234.major.rst", True, False), - ("1234.skip", False, True), - ("jdoe-fix-bug.rst", True, False), - ("jdoe-add-feature.minor.rst", True, False), - ("jdoe-rename-api.major.rst", True, False), - ("jdoe-ci-only.skip", False, True), - (".gitkeep", False, False), - ("README.md", False, False), - ("1234.patch.rst", False, False), # only minor/major are recognised tiers - ("foo.bar.rst", False, False), # extra dots in slug are reserved for tier suffix - ("1234.minor", False, False), # missing .rst extension - ("1234.rst.bak", False, False), - ], -) -def test_fragment_filename_regexes(name, is_fragment, is_skip): - assert bool(cli.FRAGMENT_RE.match(name)) is is_fragment - assert bool(cli.SKIP_RE.match(name)) is is_skip - - -# --------------------------------------------------------------------------- -# Fragment.parse_slug — derived from filename for collision detection -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "name,expected_slug", - [ - ("1234.rst", "1234"), - ("jdoe-add-feature.minor.rst", "jdoe-add-feature"), - ("blee-rename-api.major.rst", "blee-rename-api"), - ("ci-only.skip", "ci-only"), - ("README.md", None), - (".gitkeep", None), - ], -) -def test_parse_slug_for_filenames(name, expected_slug): - assert cli.Fragment.parse_slug(name) == expected_slug diff --git a/tools/changelog/test/test_cli.py b/tools/changelog/test/test_cli.py new file mode 100644 index 000000000000..4598c21e2648 --- /dev/null +++ b/tools/changelog/test/test_cli.py @@ -0,0 +1,228 @@ +# 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 argparse +import datetime +import shutil +import subprocess + +import cli +import pytest +import tomllib + +CHANGELOG = "Changelog\n---------\n\n.. towncrier release notes start\n" +SOURCES = ( + '[tool.uv.sources]\nother = { path = "source/other", editable = true }\n' + 'pkg = { path = "source/pkg", editable = true }\n' +) + + +def lock(pkg="1.0.0", other="1.0.0", header="version = 1\n"): + """Return a ``uv.lock`` pinning the two local packages at the given versions.""" + packages = {"other": other, "pkg": pkg} + return header + "".join( + f'\n[[package]]\nname = "{name}"\nversion = "{version}"\nsource = {{ editable = "source/{name}" }}\n' + for name, version in packages.items() + ) + + +FRAGMENTS = "source/pkg/changelog.d/" + + +def git(repo, *args): + return subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True).stdout + + +def write(repo, files): + for path, text in files.items(): + (repo / path).parent.mkdir(parents=True, exist_ok=True) + (repo / path).write_text(text) + + +def commit(repo, message): + git(repo, "add", "-A") + git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", message) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + """Packages ``pkg`` and ``other`` at version 1.0.0, locked, with ``origin/develop`` at ``HEAD``.""" + for pkg in ("pkg", "other"): + write( + tmp_path, + { + f"source/{pkg}/docs/CHANGELOG.rst": CHANGELOG, + f"source/{pkg}/pyproject.toml": f'[project]\nname = "{pkg}"\nversion = "1.0.0"\n', + f"source/{pkg}/changelog.d/.gitkeep": "", + f"source/{pkg}/code.py": "", + }, + ) + write(tmp_path, {"uv.lock": lock(), "pyproject.toml": SOURCES}) + git(tmp_path, "init", "-q", "-b", "develop") + commit(tmp_path, "base") + git(tmp_path, "update-ref", "refs/remotes/origin/develop", "HEAD") + monkeypatch.setattr(cli, "REPO_ROOT", tmp_path) + return tmp_path + + +def check(include_worktree=False): + return cli.cmd_check(argparse.Namespace(base_ref="develop", include_worktree=include_worktree)) + + +@pytest.mark.parametrize( + ("files", "status"), + [ + ({FRAGMENTS + "a.fixed.rst": "* Fixed x.\n More on x.\n", FRAGMENTS + "a.major": ""}, 0), + ({FRAGMENTS + "a.skip": ""}, 0), + ({FRAGMENTS + "a.minor.rst": "* Added x.\n"}, 1), # pre-towncrier name + ({FRAGMENTS + "a.minor": ""}, 1), # a tier file alone is not an entry + ({FRAGMENTS + "a.fixed.rst": ""}, 1), + ({FRAGMENTS + "a.fixed.rst": "* Fixed x.\nFlush-left paragraph.\n"}, 1), + ], +) +def test_check_requires_a_valid_fragment_for_a_changed_package(repo, files, status): + write(repo, {"source/pkg/code.py": "x = 1\n", **files}) + commit(repo, "change") + assert check() == status + + +def test_check_rejects_editing_or_deleting_a_pending_fragment(repo, capsys): + write(repo, {FRAGMENTS + "a.fixed.rst": "* Fixed x.\n", FRAGMENTS + "b.fixed.rst": "* Fixed y.\n"}) + commit(repo, "pending fragments") + git(repo, "update-ref", "refs/remotes/origin/develop", "HEAD") + write(repo, {FRAGMENTS + "a.fixed.rst": "* Fixed x differently.\n"}) + (repo / FRAGMENTS / "b.fixed.rst").unlink() + git(repo, "add", "-A") + assert check(include_worktree=True) == 1 + errors = capsys.readouterr().out + assert FRAGMENTS + "a.fixed.rst" in errors and FRAGMENTS + "b.fixed.rst" in errors + + +def test_check_ignores_changes_outside_packages(repo): + write(repo, {"tools/script.py": "x = 1\n"}) + git(repo, "add", "-A") + assert check(include_worktree=True) == 0 + + +@pytest.mark.parametrize( + "files", + [ + {"source/other/docs/CHANGELOG.rst": CHANGELOG.replace("start\n", "start here\n")}, # marker line altered + {"source/other/pyproject.toml": '[project]\nname = "other"\nversion = "1.0.1"\n'}, # lock pin stale + {"pyproject.toml": SOURCES + 'new = { path = "source/new", editable = true }\n'}, # package not locked + ], +) +def test_check_requires_the_marker_and_current_lock_pins(repo, files): + write(repo, {**files, "source/other/changelog.d/a.skip": ""}) + git(repo, "add", "-A") + assert check(include_worktree=True) == 1 + + +def test_compile_refuses_to_overwrite_a_fragment_when_splitting(repo): + write(repo, {FRAGMENTS + "a.rst": "Fixed\n^^^^^\n\n* Fixed x.\n", FRAGMENTS + "a.fixed.rst": "* Fixed y.\n"}) + with pytest.raises(ValueError): + cli.compile_package(repo / "source/pkg") + assert (repo / FRAGMENTS / "a.fixed.rst").read_text() == "* Fixed y.\n" + + +def fake_tools(repo, monkeypatch, relocked, stray=False, lock_fails=False): + """Replace uv and towncrier with file edits that mimic them; git still runs for real.""" + run = cli.run + + def fake_run(*cmd, env=None): + if cmd[:2] == ("uv", "version"): + write(repo, {"source/pkg/pyproject.toml": '[project]\nname = "pkg"\nversion = "1.0.1"\n'}) + return "1.0.1\n" + if cmd[: len(cli.TOWNCRIER)] == cli.TOWNCRIER: + write(repo, {"source/pkg/docs/CHANGELOG.rst": CHANGELOG + "\n1.0.1 (today)\n"}) + if stray: + write(repo, {"notes.txt": "a file no compile output should touch"}) + (repo / FRAGMENTS / "a.fixed.rst").unlink() + return "" + if cmd[:2] == ("uv", "lock"): + write(repo, {"uv.lock": relocked}) + if lock_fails: + raise subprocess.CalledProcessError(1, cmd, stderr="resolution failed") + return "" + return run(*cmd, env=env) + + monkeypatch.setattr(cli, "run", fake_run) + + +def test_compile_stages_only_its_outputs(repo, monkeypatch): + # ``other`` has only a leftover tier file: no release, and the file is dropped. + write(repo, {FRAGMENTS + "a.fixed.rst": "* Fixed x.\n", "source/other/changelog.d/b.minor": ""}) + commit(repo, "fragments") + fake_tools(repo, monkeypatch, relocked=lock(pkg="1.0.1"), stray=True) + assert cli.cmd_compile(argparse.Namespace()) == 1 # the stray notes.txt a tool wrote is reported + staged = set(git(repo, "diff", "--cached", "--name-only").split()) + assert staged == { + "source/pkg/pyproject.toml", + "source/pkg/docs/CHANGELOG.rst", + FRAGMENTS + "a.fixed.rst", + "source/other/changelog.d/b.minor", + "uv.lock", + } + + +def test_compile_refuses_a_dirty_checkout(repo, monkeypatch): + write(repo, {FRAGMENTS + "a.fixed.rst": "* Fixed x.\n", "notes.txt": "local work"}) + run = cli.run + monkeypatch.setattr(cli, "run", lambda *cmd, env=None: run(*cmd) if cmd[0] == "git" else pytest.fail(f"ran {cmd}")) + assert cli.cmd_compile(argparse.Namespace()) == 1 + assert (repo / "notes.txt").read_text() == "local work" + + +ROOT = '\n[[package]]\nname = "root"\nversion = "{}"\nsource = {{ virtual = "." }}\n' + + +@pytest.mark.parametrize( + ("base", "relocked", "lock_fails"), + [ + (lock(), lock(header="version = 1\nrevision = 3\n"), False), + (lock() + ROOT.format("0.1.0"), lock(pkg="1.0.1") + ROOT.format("0.2.0"), False), # only versions move + (lock(), lock(pkg="1.0.1"), True), + ], + ids=["re-serialized", "non-local-version-moved", "uv-lock-failed"], +) +def test_compile_aborts_when_the_relock_fails(repo, monkeypatch, base, relocked, lock_fails): + write(repo, {"uv.lock": base, FRAGMENTS + "a.fixed.rst": "* Fixed x.\n", "source/other/changelog.d/b.skip": ""}) + commit(repo, "fragments") + fake_tools(repo, monkeypatch, relocked, lock_fails=lock_fails) + assert cli.cmd_compile(argparse.Namespace()) == 1 + assert git(repo, "status", "--porcelain") == "" + + +@pytest.mark.skipif(not (shutil.which("uv") and shutil.which("uvx")), reason="needs uv and uvx") +def test_compile_renders_the_changelog_with_real_uv_and_towncrier(repo, monkeypatch): + """Runs the real ``uv version`` and towncrier with the repository config and template. + + An already-merged pre-towncrier ``d.major.rst`` is split, and its tier outranks ``a.minor``. + """ + shutil.copytree(cli.CONFIG.parent, repo / "tools/changelog", ignore=shutil.ignore_patterns("test", "__pycache__")) + monkeypatch.setattr(cli, "CONFIG", repo / "tools/changelog/towncrier.toml") + old_entry = "\n1.0.0 (2026-01-01)\n~~~~~~~~~~~~~~~~~~\n\nAdded\n^^^^^\n\n* Initial.\n" + write( + repo, + { + "source/pkg/docs/CHANGELOG.rst": CHANGELOG + old_entry, + FRAGMENTS + "b.fixed.rst": "* Fixed y.\n", + FRAGMENTS + "a.added.rst": "* Added x.\n More on x.\n", + FRAGMENTS + "a.minor": "", + FRAGMENTS + "c.skip": "", + FRAGMENTS + "d.major.rst": "Fixed\n^^^^^\n\n* Fixed z.\n", + }, + ) + commit(repo, "fragments") + assert cli.compile_package(repo / "source/pkg") == "2.0.0" + today = datetime.date.today().isoformat() + title = f"2.0.0 ({today})" + new_entry = ( + f"\n{title}\n{'~' * len(title)}\n\nAdded\n^^^^^\n\n* Added x.\n More on x.\n\n" + "Fixed\n^^^^^\n\n* Fixed y.\n* Fixed z.\n\n" + ) + assert (repo / "source/pkg/docs/CHANGELOG.rst").read_text() == CHANGELOG + new_entry + old_entry + assert [p.name for p in (repo / FRAGMENTS).iterdir()] == [".gitkeep"] + assert tomllib.loads((repo / "source/pkg/pyproject.toml").read_text())["project"]["version"] == "2.0.0" diff --git a/tools/changelog/test/test_format.py b/tools/changelog/test/test_format.py deleted file mode 100644 index 59fc29b0166c..000000000000 --- a/tools/changelog/test/test_format.py +++ /dev/null @@ -1,94 +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 - -"""FragmentBatch._merge_sections + ._format_entry + Version.bumped — the rendering pipeline.""" - -from __future__ import annotations - -import cli -import pytest - -# --------------------------------------------------------------------------- -# merge_fragments — collapses bullets across fragments under the same section -# --------------------------------------------------------------------------- - - -def test_merge_fragments_collapses_same_section_across_fragments(): - f1 = {"Added": ["* a1\n"]} - f2 = {"Added": ["* a2\n"], "Fixed": ["* f1\n"]} - merged = cli.FragmentBatch._merge_sections([f1, f2]) - # Bullets from separate fragments concatenate with no blank line in between - # (matching IsaacLab's repo convention, where successive bullets are run-on). - assert merged["Added"] == ["* a1\n", "* a2\n"] - assert merged["Fixed"] == ["* f1\n"] - - -# --------------------------------------------------------------------------- -# format_entry — section ordering + version heading -# --------------------------------------------------------------------------- - - -def test_format_entry_orders_canonical_sections(): - sections = { - "Fixed": ["* f1\n"], - "Added": ["* a1\n"], - "Removed": ["* r1\n"], - } - out = cli.FragmentBatch._format_entry("1.2.4", sections) - # Canonical order is Added, Changed, Deprecated, Removed, Fixed. - a_pos = out.index("Added") - r_pos = out.index("Removed") - f_pos = out.index("Fixed") - assert a_pos < r_pos < f_pos - - -def test_format_entry_includes_version_heading(): - out = cli.FragmentBatch._format_entry("9.9.9", {"Added": ["* x\n"]}) - assert "9.9.9 (" in out - assert "~~~~~~" in out # tilde underline - - -def test_format_entry_unknown_sections_appear_after_canonical(): - sections = {"Performance": ["* p1\n"], "Added": ["* a1\n"]} - out = cli.FragmentBatch._format_entry("1.0.0", sections) - assert out.index("Added") < out.index("Performance") - - -# --------------------------------------------------------------------------- -# bump_version — semver maths -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "current,part,expected", - [ - ("1.2.3", "patch", "1.2.4"), - ("1.2.3", "minor", "1.3.0"), # minor bump zeros patch - ("1.2.3", "major", "2.0.0"), # major bump zeros minor and patch - ("4.6.21", "patch", "4.6.22"), - ("4.6.21.dev20260301", "patch", "4.6.22"), # dev suffix stripped - ], -) -def test_version_bumped(current, part, expected): - assert cli.Version(current).bumped(part).text == expected - assert str(cli.Version(current).bumped(part)) == expected - - -def test_version_bumped_rejects_non_semver(): - # Construction itself rejects malformed input — fail-fast for bad ``--version``. - with pytest.raises(ValueError): - cli.Version("1.2") - with pytest.raises(ValueError): - cli.Version("not-semver") - with pytest.raises(ValueError): - cli.Version("1.2.3.4.5") - - -def test_version_accepts_dev_suffix(): - """PEP 440 ``.devN`` suffixes are tolerated on construction (they appear in - real ``pyproject.toml`` files between releases) and stripped on bump.""" - v = cli.Version("4.6.21.dev20260301") - assert v.text == "4.6.21.dev20260301" - assert v.bumped("patch").text == "4.6.22" diff --git a/tools/changelog/test/test_git_diff.py b/tools/changelog/test/test_git_diff.py deleted file mode 100644 index b9c73e3b447e..000000000000 --- a/tools/changelog/test/test_git_diff.py +++ /dev/null @@ -1,56 +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 - -"""Git-backed tests for changelog pull-request diff collection.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - -import cli - - -def _git(repo: Path, *args: str) -> None: - """Run Git in a temporary repository.""" - subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) - - -def test_from_git_include_worktree_collects_tracked_local_changes(tmp_path, monkeypatch): - """Local checks include tracked changes but ignore untracked files.""" - _git(tmp_path, "init", "-b", "develop") - _git(tmp_path, "config", "user.email", "test@example.com") - _git(tmp_path, "config", "user.name", "Test User") - - package_dir = tmp_path / "source" / "example" - package_dir.mkdir(parents=True) - (package_dir / "base.py").write_text("base\n", encoding="utf-8") - _git(tmp_path, "add", "source/example/base.py") - _git(tmp_path, "commit", "-m", "Add base") - _git(tmp_path, "update-ref", "refs/remotes/origin/develop", "HEAD") - _git(tmp_path, "switch", "-c", "feature") - - (package_dir / "committed.py").write_text("committed\n", encoding="utf-8") - _git(tmp_path, "add", "source/example/committed.py") - _git(tmp_path, "commit", "-m", "Add committed change") - - (package_dir / "staged.py").write_text("staged\n", encoding="utf-8") - _git(tmp_path, "add", "source/example/staged.py") - (package_dir / "base.py").write_text("unstaged\n", encoding="utf-8") - (package_dir / "changelog.d").mkdir() - (package_dir / "changelog.d" / "untracked.skip").touch() - - monkeypatch.setattr(cli, "REPO_ROOT", tmp_path) - assert cli.PRDiff.from_git("develop", include_worktree=True) == cli.PRDiff( - changed={ - "source/example/base.py", - "source/example/committed.py", - "source/example/staged.py", - }, - added={ - "source/example/committed.py", - "source/example/staged.py", - }, - ) diff --git a/tools/changelog/test/test_integration.py b/tools/changelog/test/test_integration.py deleted file mode 100644 index 91906152c5c3..000000000000 --- a/tools/changelog/test/test_integration.py +++ /dev/null @@ -1,69 +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 - -"""End-to-end checks: run the compiler against each worked example and verify -the resulting changelog matches the checked-in :file:`changelog_after.rst`. - -This is what makes the examples *living docs* — if anything in the compile -pipeline drifts, an example's ``changelog_after.rst`` stops matching and -the corresponding test fails immediately. -""" - -from __future__ import annotations - -import re -import shutil -from pathlib import Path - -import cli -import pytest - -EXAMPLES = Path(__file__).parent / "integration" - -# Strip the ``(YYYY-MM-DD)`` suffix from version headings so the fixed example -# files don't drift when the compiler stamps today's date. -_DATE_RE = re.compile(r"\(\d{4}-\d{2}-\d{2}\)") - - -def _normalize(text: str) -> str: - return _DATE_RE.sub("(YYYY-MM-DD)", text) - - -@pytest.mark.parametrize( - "demo,expected_version", - [ - ("01_patch_bump", "1.2.4"), - ("02_minor_bump", "1.3.0"), - ("03_major_bump", "2.0.0"), - ], -) -def test_demo_compile_matches_changelog_after(tmp_path, demo, expected_version): - """Stage a fake package whose CHANGELOG.rst matches the demo's ``before``, - run the compiler against the demo's fragments, and verify the file ends - up byte-equal to the demo's ``after`` (modulo today's date).""" - demo_dir = EXAMPLES / demo - - # Build a minimal package layout the compiler will accept. - pkg_root = tmp_path / "demo_pkg" - pkg_root.mkdir(parents=True) - (pkg_root / "docs").mkdir(parents=True) - (pkg_root / "pyproject.toml").write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") - shutil.copy(demo_dir / "changelog_before.rst", pkg_root / "docs" / "CHANGELOG.rst") - - # Copy fragments into tmp_path so the compile's auto-clean doesn't - # delete the live checked-in examples directory. - fragments_tmp = tmp_path / "fragments" - shutil.copytree(demo_dir / "fragments", fragments_tmp) - - # Run the compiler against the (copied) fragments. - pkg = cli.Package(pkg_root) - pkg.compile(fragments_dir=fragments_tmp) - - actual = (pkg_root / "docs" / "CHANGELOG.rst").read_text(encoding="utf-8") - expected = (demo_dir / "changelog_after.rst").read_text(encoding="utf-8") - assert _normalize(actual) == _normalize(expected) - - # Version should have bumped exactly as the demo name suggests. - assert str(pkg.current_version()) == expected_version diff --git a/tools/changelog/test/test_parse.py b/tools/changelog/test/test_parse.py deleted file mode 100644 index 22dd630afe8e..000000000000 --- a/tools/changelog/test/test_parse.py +++ /dev/null @@ -1,143 +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 - -"""Fragment.parse + FragmentBatch.from_dir + Package.discover — directory scanning.""" - -from __future__ import annotations - -from pathlib import Path - -import cli - -FIXTURES = Path(__file__).parent - - -def _write(path: Path, body: str) -> Path: - path.write_text(body, encoding="utf-8") - return path - - -# --------------------------------------------------------------------------- -# parse_fragment — section header detection (pure function) -# --------------------------------------------------------------------------- - - -def test_parse_fragment_single_section(tmp_path): - p = _write(tmp_path / "1.rst", "Added\n^^^^^\n\n* Added :class:`~pkg.Foo`.\n") - sections = cli.Fragment(p).parse() - assert list(sections.keys()) == ["Added"] - assert sections["Added"] == ["* Added :class:`~pkg.Foo`.\n"] - - -def test_parse_fragment_multiple_sections_preserves_dict_order(tmp_path): - p = _write(tmp_path / "1.rst", "Added\n^^^^^\n\n* a1\n\nFixed\n^^^^^\n\n* f1\n* f2\n") - sections = cli.Fragment(p).parse() - assert list(sections.keys()) == ["Added", "Fixed"] - assert sections["Added"] == ["* a1\n"] - assert sections["Fixed"] == ["* f1\n", "* f2\n"] - - -def test_parse_fragment_underline_must_be_at_least_heading_length(tmp_path): - """Heading 'Added' (5 chars) needs >=5 carets; '^^' must not match.""" - p = _write(tmp_path / "1.rst", "Added\n^^\n\n* a1\n") - assert cli.Fragment(p).parse() == {} - - -def test_parse_fragment_empty_file(tmp_path): - p = _write(tmp_path / "1.rst", "") - assert cli.Fragment(p).parse() == {} - - -def test_parse_fragment_no_section_headings(tmp_path): - p = _write(tmp_path / "1.rst", "Just a free-form note with no headings.\n") - assert cli.Fragment(p).parse() == {} - - -# --------------------------------------------------------------------------- -# Fragment.parse — same logic, exposed as a method on the wrapper -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# FragmentBatch.from_dir — separates valid filenames from the rest -# --------------------------------------------------------------------------- - - -def test_fragment_batch_flags_invalid_filenames_from_fixture(): - """Files with dotted slugs or unknown bump tiers go in ``invalid``.""" - batch = cli.FragmentBatch.from_dir(FIXTURES / "invalid_filenames") - assert batch.valid == [] - assert {p.name for p in batch.invalid} == {"multi.dot.slug.rst", "1234.notabump.rst"} - - -def test_fragment_batch_missing_directory(tmp_path): - """A non-existent directory is treated as empty, not an error.""" - batch = cli.FragmentBatch.from_dir(tmp_path / "does-not-exist") - assert batch.valid == [] - assert batch.invalid == [] - assert batch.skip_paths == [] - - -def test_fragment_batch_collects_skip_files_separately(tmp_path): - """``.skip`` files are tolerated — exposed via ``skip_paths``, not ``valid``.""" - (tmp_path / "1234.skip").write_text("", encoding="utf-8") - (tmp_path / "1235.rst").write_text("Added\n^^^^^\n\n* x\n", encoding="utf-8") - batch = cli.FragmentBatch.from_dir(tmp_path) - assert {f.name for f in batch.valid} == {"1235.rst"} - assert {p.name for p in batch.skip_paths} == {"1234.skip"} - - -# --------------------------------------------------------------------------- -# Package.discover — a package is "managed" iff it has both -# pyproject.toml and docs/CHANGELOG.rst -# --------------------------------------------------------------------------- - - -def _make_pkg(root: Path, name: str, *, has_ext: bool = True, has_changelog: bool = True) -> None: - pkg = root / name - if has_ext: - pkg.mkdir(parents=True, exist_ok=True) - (pkg / "pyproject.toml").write_text('[project]\nversion = "0.0.0"\n', encoding="utf-8") - if has_changelog: - (pkg / "docs").mkdir(parents=True, exist_ok=True) - (pkg / "docs" / "CHANGELOG.rst").write_text("Changelog\n---------\n\n", encoding="utf-8") - - -def test_package_discover_includes_complete_packages(tmp_path): - _make_pkg(tmp_path, "complete_a") - _make_pkg(tmp_path, "complete_b") - pkgs = cli.Package.discover(tmp_path) - assert [p.name for p in pkgs] == ["complete_a", "complete_b"] - assert all(p.is_managed for p in pkgs) - - -def test_package_discover_excludes_packages_missing_changelog(tmp_path): - _make_pkg(tmp_path, "complete") - _make_pkg(tmp_path, "no_changelog", has_changelog=False) - assert [p.name for p in cli.Package.discover(tmp_path)] == ["complete"] - - -def test_package_discover_excludes_packages_missing_pyproject_toml(tmp_path): - _make_pkg(tmp_path, "complete") - _make_pkg(tmp_path, "no_extension", has_ext=False) - assert [p.name for p in cli.Package.discover(tmp_path)] == ["complete"] - - -def test_package_discover_returns_sorted_alphabetically(tmp_path): - _make_pkg(tmp_path, "zebra") - _make_pkg(tmp_path, "alpha") - _make_pkg(tmp_path, "mango") - assert [p.name for p in cli.Package.discover(tmp_path)] == ["alpha", "mango", "zebra"] - - -def test_package_discover_missing_root_returns_empty(tmp_path): - assert cli.Package.discover(tmp_path / "does-not-exist") == [] - - -def test_package_is_managed_property(tmp_path): - _make_pkg(tmp_path, "complete") - _make_pkg(tmp_path, "no_changelog", has_changelog=False) - assert cli.Package(tmp_path / "complete").is_managed is True - assert cli.Package(tmp_path / "no_changelog").is_managed is False diff --git a/tools/changelog/test/test_validate.py b/tools/changelog/test/test_validate.py deleted file mode 100644 index 369a5c390489..000000000000 --- a/tools/changelog/test/test_validate.py +++ /dev/null @@ -1,509 +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 - -"""Fragment.validate — PR-gate filename + content rules.""" - -from __future__ import annotations - -from pathlib import Path - -import cli -import pytest - -FIXTURES = Path(__file__).parent - - -def _write(path: Path, body: str) -> Path: - path.write_text(body, encoding="utf-8") - return path - - -# --------------------------------------------------------------------------- -# Acceptance — well-formed fragments -# --------------------------------------------------------------------------- - - -def test_validate_accepts_well_formed(tmp_path): - p = _write(tmp_path / "1234.rst", "Added\n^^^^^\n\n* Added X.\n") - assert cli.Fragment(p).validate() is None - - -def test_validate_accepts_minor_suffix(tmp_path): - p = _write(tmp_path / "1234.minor.rst", "Added\n^^^^^\n\n* Added X.\n") - assert cli.Fragment(p).validate() is None - - -def test_validate_accepts_major_suffix(tmp_path): - p = _write(tmp_path / "1234.major.rst", "Removed\n^^^^^^^\n\n* Removed X.\n") - assert cli.Fragment(p).validate() is None - - -# --------------------------------------------------------------------------- -# Rejection — uses checked-in fixtures so the malformed inputs are reviewable -# --------------------------------------------------------------------------- - - -def test_validate_rejects_unknown_filename_from_fixture(): - err = cli.Fragment(FIXTURES / "invalid_filenames" / "multi.dot.slug.rst").validate() - assert err is not None and "invalid filename" in err - - -def test_validate_rejects_unknown_bump_tier_from_fixture(): - err = cli.Fragment(FIXTURES / "invalid_filenames" / "1234.notabump.rst").validate() - assert err is not None and "invalid filename" in err - - -def test_validate_rejects_empty_file_from_fixture(): - err = cli.Fragment(FIXTURES / "invalid_content" / "3001.rst").validate() - assert err is not None and "empty" in err - - -def test_validate_rejects_missing_section_heading_from_fixture(): - err = cli.Fragment(FIXTURES / "invalid_content" / "3002.rst").validate() - assert err is not None and "section" in err.lower() - - -def test_validate_rejects_section_without_bullets_from_fixture(): - err = cli.Fragment(FIXTURES / "invalid_content" / "3003.rst").validate() - assert err is not None and "bullet" in err.lower() - - -def test_validate_rejects_orphan_paragraph_from_fixture(): - """A flush-left paragraph between bullets / after the last bullet must be - rejected — the compile step would splice it verbatim into ``CHANGELOG.rst`` - and Sphinx then fails the doc build with ``Unexpected indentation``.""" - err = cli.Fragment(FIXTURES / "invalid_content" / "3004.rst").validate() - assert err is not None and "orphan" in err.lower() - - -# --------------------------------------------------------------------------- -# check_fragments — gate orchestration: immutability, slug uniqueness, and -# the "PR must add at least one fragment per touched package" rule -# --------------------------------------------------------------------------- - - -def _pkg_under(tmp_path: Path, name: str) -> cli.Package: - """Build a managed-looking Package rooted at ``tmp_path/source/``.""" - root = tmp_path / "source" / name - root.mkdir(parents=True) - (root / "docs").mkdir(parents=True) - (root / "pyproject.toml").write_text('[project]\nversion = "0.0.0"\n', encoding="utf-8") - (root / "docs" / "CHANGELOG.rst").write_text("Changelog\n---------\n\n", encoding="utf-8") - return cli.Package(root) - - -def test_check_fragments_immutability_rejects_modified_fragment(tmp_path): - """Modifying an existing fragment is forbidden — must add a new one instead.""" - pkg = _pkg_under(tmp_path, "isaaclab") - changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/jdoe-fix-bug.rst"} - added = {"source/isaaclab/code.py"} # fragment exists already; the PR only modified it - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - assert missing == ["isaaclab"] - invalid_map = dict(invalid) - assert "source/isaaclab/changelog.d/jdoe-fix-bug.rst" in invalid_map - assert "immutable" in invalid_map["source/isaaclab/changelog.d/jdoe-fix-bug.rst"] - - -def test_check_fragments_chain_allows_other_pr_fragment(tmp_path): - """A chained PR (B based on A's branch, A still open) sees A's fragment in - its diff. That should pass — both fragments have distinct slugs and B - contributes its own fragment for the touched package.""" - pkg = _pkg_under(tmp_path, "isaaclab") - (pkg.root / "changelog.d").mkdir() - (pkg.root / "changelog.d" / "alice-feature-a.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") - (pkg.root / "changelog.d" / "bob-feature-b.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") - changed = { - "source/isaaclab/code.py", - "source/isaaclab/changelog.d/alice-feature-a.rst", # parent PR's fragment - "source/isaaclab/changelog.d/bob-feature-b.rst", # this PR's own fragment - } - added = changed - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - assert missing == [] - assert invalid == [] - - -def test_check_fragments_slug_collision_with_existing(tmp_path): - """Adding a fragment whose slug collides with one already in changelog.d/ fails.""" - pkg = _pkg_under(tmp_path, "isaaclab") - (pkg.root / "changelog.d").mkdir() - # Pre-existing fragment on develop with the same slug as the one this PR adds. - (pkg.root / "changelog.d" / "jdoe-fix-bug.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") - # PR adds a fresh fragment whose slug collides — different tier, same slug. - (pkg.root / "changelog.d" / "jdoe-fix-bug.minor.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") - changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/jdoe-fix-bug.minor.rst"} - added = changed - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - invalid_map = dict(invalid) - assert "source/isaaclab/changelog.d/jdoe-fix-bug.minor.rst" in invalid_map - assert "collides" in invalid_map["source/isaaclab/changelog.d/jdoe-fix-bug.minor.rst"] - - -def test_check_fragments_collision_independent_of_iterdir_order(tmp_path, monkeypatch): - """Regression: an added file must not be allowed to *replace* a colliding - pre-existing fragment in the existing-slug map. The CI checkout contains - both, and depending on filesystem iteration order the added file could - end up as the "existing" entry, hiding the collision.""" - pkg = _pkg_under(tmp_path, "isaaclab") - (pkg.root / "changelog.d").mkdir() - (pkg.root / "changelog.d" / "jdoe-foo.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") - (pkg.root / "changelog.d" / "jdoe-foo.minor.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") - changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/jdoe-foo.minor.rst"} - added = changed - - # Force iterdir() to return the added file *last* so it would overwrite - # the pre-existing entry in a buggy implementation. Sort with the added - # file ranked highest, so it lands at the tail regardless of natural - # alphabetical order. - real_iterdir = Path.iterdir - added_name = "jdoe-foo.minor.rst" - - def ordered_iterdir(self): - if self == pkg.root / "changelog.d": - return iter(sorted(real_iterdir(self), key=lambda p: (p.name == added_name, p.name))) - return real_iterdir(self) - - monkeypatch.setattr(Path, "iterdir", ordered_iterdir) - - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - invalid_map = dict(invalid) - assert "source/isaaclab/changelog.d/jdoe-foo.minor.rst" in invalid_map - assert "collides" in invalid_map["source/isaaclab/changelog.d/jdoe-foo.minor.rst"] - - -def test_check_fragments_slug_collision_within_pr(tmp_path): - """Two added fragments in the same PR that share a slug (e.g. across tiers) fail.""" - pkg = _pkg_under(tmp_path, "isaaclab") - (pkg.root / "changelog.d").mkdir() - (pkg.root / "changelog.d" / "jdoe-fix.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") - (pkg.root / "changelog.d" / "jdoe-fix.minor.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") - changed = { - "source/isaaclab/code.py", - "source/isaaclab/changelog.d/jdoe-fix.rst", - "source/isaaclab/changelog.d/jdoe-fix.minor.rst", - } - added = changed - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - # One of the two is the offender; the other is the first-seen "winner". - invalid_paths = [p for p, _ in invalid] - assert any("jdoe-fix" in p for p in invalid_paths) - assert any("collides" in r for _, r in invalid) - - -def test_check_fragments_skip_file_satisfies_requirement(tmp_path): - """A ``.skip`` opt-out is a valid form of "PR owns a fragment for this pkg".""" - pkg = _pkg_under(tmp_path, "isaaclab") - (pkg.root / "changelog.d").mkdir() - (pkg.root / "changelog.d" / "ci-only.skip").write_text("", encoding="utf-8") - changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/ci-only.skip"} - added = changed - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - assert missing == [] - assert invalid == [] - - -def test_check_fragments_no_source_changes_means_no_required_fragment(tmp_path): - """Pure docs / CI / changelog-tooling PRs don't trigger the requirement.""" - pkg = _pkg_under(tmp_path, "isaaclab") - changed = {"docs/something.rst"} # not under source/isaaclab/ - added = changed - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - assert missing == [] - assert invalid == [] - - -def test_check_fragments_missing_when_source_touched_without_fragment(tmp_path): - """If the PR touches a package's source but adds no fragment, the package is missing.""" - pkg = _pkg_under(tmp_path, "isaaclab") - changed = {"source/isaaclab/code.py"} - added = changed - missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) - assert missing == ["isaaclab"] - assert invalid == [] - - -# --------------------------------------------------------------------------- -# _display_path — handles paths inside *and* outside REPO_ROOT -# --------------------------------------------------------------------------- - - -def test_display_path_strips_repo_root_for_internal_paths(): - """Inside-repo paths are shown relative for terse log lines.""" - p = cli.REPO_ROOT / "tools" / "changelog" / "cli.py" - assert cli._display_path(p) == "tools/changelog/cli.py" - - -def test_display_path_falls_back_to_absolute_for_external(tmp_path): - """External paths (e.g. ``--fragments-dir /tmp/foo`` outside the repo) - used to crash on ``relative_to(REPO_ROOT)``; the helper now returns the - absolute path in that case.""" - external = tmp_path / "external_fragments" / "1234.rst" - external.parent.mkdir(parents=True) - external.write_text("", encoding="utf-8") - assert cli._display_path(external) == str(external) - - -# --------------------------------------------------------------------------- -# Package.compile bails on unmanaged packages instead of silently warning -# --------------------------------------------------------------------------- - - -def test_compile_raises_on_package_missing_changelog(tmp_path): - """Constructing a Package directly at an unmanaged root and calling - ``compile()`` must raise (not silently warn-and-write a stale toml).""" - pkg_root = tmp_path / "pkg" - pkg_root.mkdir(parents=True) - (pkg_root / "pyproject.toml").write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") - # No docs/CHANGELOG.rst — package is not managed. - pkg = cli.Package(pkg_root) - assert pkg.is_managed is False - - fragments = tmp_path / "fragments" - fragments.mkdir() - (fragments / "1234.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") - - with pytest.raises(ValueError, match="not managed"): - pkg.compile(fragments_dir=fragments, dry_run=True) - - -# --------------------------------------------------------------------------- -# cmd_compile parser guards — argparse-level errors fire as SystemExit -# --------------------------------------------------------------------------- - - -def _parse_compile(argv: list[str]): - """Build the parser and parse a compile invocation. Returns (parser, args).""" - parser = cli._build_parser() - return parser, parser.parse_args(argv) - - -def test_compile_guard_version_with_all_errors(): - """``--version`` with ``--all`` is meaningless — each package has its own version.""" - parser, args = _parse_compile(["compile", "--all", "--version", "1.2.3"]) - with pytest.raises(SystemExit): - cli.cmd_compile(args, parser) - - -def test_compile_guard_fragments_dir_with_all_errors(): - """``--fragments-dir`` with ``--all`` is meaningless — different dirs per package.""" - parser, args = _parse_compile(["compile", "--all", "--fragments-dir", "/tmp/x"]) - with pytest.raises(SystemExit): - cli.cmd_compile(args, parser) - - -def test_compile_guard_malformed_version_errors(): - """A garbage ``--version`` value fails before any file is touched.""" - parser, args = _parse_compile(["compile", "--package", "isaaclab", "--version", "not-semver"]) - with pytest.raises(SystemExit): - cli.cmd_compile(args, parser) - - -def test_compile_guard_nonexistent_package_errors(): - """A ``--package`` that doesn't exist on disk fails fast.""" - parser, args = _parse_compile(["compile", "--package", "definitely_not_a_real_package_xyz"]) - with pytest.raises(SystemExit): - cli.cmd_compile(args, parser) - - -def test_changelog_header_re_accepts_minimum_stub(): - """The minimum-valid stub ``Changelog\\n---------\\n\\n`` is accepted. - - This is the smallest seed a new package needs to start receiving - bot-written entries. Anything shorter (e.g. ``Changelog\\n---------\\n`` - without a trailing blank line) leaves no anchor for the prepend and - must be rejected. - """ - assert cli.CHANGELOG_HEADER_RE.search("Changelog\n---------\n\n") is not None - - -def test_changelog_header_re_rejects_unterminated_stub(): - """A header missing the trailing blank line cannot anchor an insert. - - Regression: ``isaaclab_ppisp`` landed on develop with this exact - 20-byte shape, wedging the nightly compile job (run - https://github.com/isaac-sim/IsaacLab/actions/runs/26494922179). - """ - assert cli.CHANGELOG_HEADER_RE.search("Changelog\n---------\n") is None - - -def test_every_managed_package_has_parseable_changelog_header(): - """Lock the compile precondition: every managed package on disk must - have a ``CHANGELOG.rst`` whose header is parseable AFTER the same - self-healing normalization compile applies. - - Mirrors what ``cmd_check`` does on every PR: self-heal the narrow - "missing trailing blank line" case, then require the strict regex to - match. A file that passes self-healing but not the regex is genuinely - malformed (no ``Changelog`` line, no underline, etc.) and would wedge - the nightly. - """ - import re - - self_heal = re.compile(r"^(Changelog\n-+)\n(?!\n)", re.MULTILINE) - bad: list[tuple[str, str]] = [] - for pkg in cli.Package.discover(): - text = pkg.changelog_path.read_text(encoding="utf-8") - text = self_heal.sub(r"\1\n\n", text, count=1) - if cli.CHANGELOG_HEADER_RE.search(text) is None: - bad.append((pkg.name, str(pkg.changelog_path))) - assert not bad, ( - "Malformed CHANGELOG.rst — each file must contain " - "'Changelog\\n---------\\n\\n' (header + underline + blank line) " - "so the nightly compile has a place to prepend the next version block. " - f"Bad files: {bad}" - ) - - -def test_write_changelog_entry_self_heals_missing_blank_line(tmp_path): - """Compile against a ``Changelog\\n---------\\n`` stub auto-inserts the - missing blank line and writes a well-formed file. - - Regression: ``isaaclab_ppisp`` shipped with this exact 20-byte shape - (PR #5748); the nightly compile raised before this self-heal landed. - """ - pkg_root = tmp_path / "pkg" - pkg_root.mkdir(parents=True) - (pkg_root / "docs").mkdir(parents=True) - (pkg_root / "pyproject.toml").write_text('[project]\nversion = "0.1.0"\n', encoding="utf-8") - # The exact malformed shape that wedged the nightly. - (pkg_root / "docs" / "CHANGELOG.rst").write_text("Changelog\n---------\n", encoding="utf-8") - pkg = cli.Package(pkg_root) - pkg.write_changelog_entry("0.2.0 (2026-05-28)\n~~~~~~~~~~~~~~~~~~\n", dry_run=False) - out = (pkg_root / "docs" / "CHANGELOG.rst").read_text(encoding="utf-8") - assert out.startswith("Changelog\n---------\n\n0.2.0 (2026-05-28)") - - -def test_write_changelog_entry_self_heal_is_noop_on_well_formed(tmp_path): - """Self-heal must not alter files that already have the blank line. - - Idempotence is the whole point of the negative-lookahead in the - self-heal regex; this test locks it. - """ - pkg_root = tmp_path / "pkg" - pkg_root.mkdir(parents=True) - (pkg_root / "docs").mkdir(parents=True) - (pkg_root / "pyproject.toml").write_text('[project]\nversion = "0.1.0"\n', encoding="utf-8") - original = "Changelog\n---------\n\n0.1.0 (2026-05-20)\n~~~~~~~~~~~~~~~~~~\n\nAdded\n^^^^^\n\n* Initial.\n" - (pkg_root / "docs" / "CHANGELOG.rst").write_text(original, encoding="utf-8") - pkg = cli.Package(pkg_root) - pkg.write_changelog_entry("0.2.0 (2026-05-28)\n~~~~~~~~~~~~~~~~~~\n", dry_run=False) - out = (pkg_root / "docs" / "CHANGELOG.rst").read_text(encoding="utf-8") - # New entry on top; the original 0.1.0 block still ends in a single ``\n`` - # before its content (no double-blank artifact from over-eager self-heal). - assert "0.2.0 (2026-05-28)" in out - assert "Changelog\n---------\n\n0.2.0" in out - assert "\n\n\n0.1.0" not in out # no extra blank line inserted - - -def test_compile_failure_preserves_fragments_and_version(tmp_path): - """A package whose ``CHANGELOG.rst`` is genuinely malformed must not - have its fragments deleted or its version bumped. - - Locks the "fail before any write" guarantee for the failure modes - per-package isolation defends against. Fragment deletion (step 6 of - ``Package.compile``) runs strictly after both writes succeed, so a - header-regex raise in step 4 short-circuits before any side effect - reaches disk. Without this guarantee, per-package isolation would - silently lose work on the failed package each cycle. - """ - pkg_root = tmp_path / "pkg" - pkg_root.mkdir(parents=True) - (pkg_root / "docs").mkdir(parents=True) - (pkg_root / "changelog.d").mkdir() - (pkg_root / "pyproject.toml").write_text('[project]\nversion = "0.1.0"\n', encoding="utf-8") - # Unrecoverable shape: no ``Changelog`` header at all — self-heal can't - # do anything and the strict regex raises ValueError. - (pkg_root / "docs" / "CHANGELOG.rst").write_text("No header at all\n", encoding="utf-8") - fragment = pkg_root / "changelog.d" / "test.rst" - fragment.write_text("Added\n^^^^^\n\n* Did a thing.\n", encoding="utf-8") - - pkg = cli.Package(pkg_root) - with pytest.raises(ValueError, match="Could not locate changelog header"): - pkg.compile(dry_run=False) - - # Fragment survived the failed compile. - assert fragment.exists(), "fragment must be preserved when compile raises" - assert fragment.read_text(encoding="utf-8") == "Added\n^^^^^\n\n* Did a thing.\n" - # Version unchanged. - assert 'version = "0.1.0"' in (pkg_root / "pyproject.toml").read_text(encoding="utf-8") - # CHANGELOG.rst unchanged (no entry prepended). - assert (pkg_root / "docs" / "CHANGELOG.rst").read_text(encoding="utf-8") == "No header at all\n" - - -def test_cmd_compile_continues_after_per_package_failure(tmp_path, monkeypatch, capsys): - """One package's compile failure must not abort the batch. - - Defense-in-depth for future failure modes (filesystem permission, direct push - bypassing PR gate). The healthy package still ships; the broken one - is named in the failure summary. - """ - packages_root = tmp_path / "source" - packages_root.mkdir() - - def _mk(name: str, changelog_text: str) -> None: - root = packages_root / name - root.mkdir(parents=True) - (root / "docs").mkdir(parents=True) - (root / "changelog.d").mkdir() - (root / "pyproject.toml").write_text('[project]\nversion = "0.1.0"\n', encoding="utf-8") - (root / "docs" / "CHANGELOG.rst").write_text(changelog_text, encoding="utf-8") - (root / "changelog.d" / "test.rst").write_text("Added\n^^^^^\n\n* Did a thing.\n", encoding="utf-8") - - # ``good_pkg`` has a canonical CHANGELOG.rst and will compile fine. - _mk("good_pkg", "Changelog\n---------\n\n") - # ``bad_pkg`` has a genuinely unrecoverable CHANGELOG.rst (no header at - # all) — self-heal doesn't apply, strict regex fails, compile raises. - _mk("bad_pkg", "Some other content with no header\n") - - # ``Package.discover`` evaluates ``PACKAGES_ROOT`` at class-definition - # time (default arg), so a monkeypatch of the module constant alone - # wouldn't redirect it. Patch the classmethod to return our two packages. - monkeypatch.setattr( - cli.Package, - "discover", - classmethod( - lambda cls: sorted([cls(packages_root / "good_pkg"), cls(packages_root / "bad_pkg")], key=lambda p: p.name) - ), - ) - parser, args = _parse_compile(["compile", "--all"]) - rc = cli.cmd_compile(args, parser) - captured = capsys.readouterr() - - assert rc == 1 - # Good package compiled — its CHANGELOG.rst now carries the new entry - # and its fragment was deleted. - assert "Added" in (packages_root / "good_pkg" / "docs" / "CHANGELOG.rst").read_text() - assert not (packages_root / "good_pkg" / "changelog.d" / "test.rst").exists() - # Bad package's fragment is preserved — per-package isolation does not - # discard work on the failed package; the next compile gets another try - # against the same fragment after the human fixes the CHANGELOG.rst. - assert (packages_root / "bad_pkg" / "changelog.d" / "test.rst").exists() - # Bad package surfaces in the failure summary. - assert "bad_pkg" in captured.err - assert "1 package(s) failed to compile" in captured.err - - -def test_compile_rejects_fragments_that_check_would_reject(tmp_path): - """``compile`` must enforce the same content rules as ``check``. - - Regression: a fragment with a section heading but no bullet body - used to slip past compile (parsed to ``{"Added": []}``, emitted an - empty Added section), while check correctly rejected it. The two - paths must agree on what a valid fragment looks like. - """ - pkg_root = tmp_path / "pkg" - pkg_root.mkdir(parents=True) - (pkg_root / "docs").mkdir(parents=True) - (pkg_root / "pyproject.toml").write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") - (pkg_root / "docs" / "CHANGELOG.rst").write_text("Changelog\n---------\n\n", encoding="utf-8") - pkg = cli.Package(pkg_root) - - fragments = tmp_path / "fragments" - fragments.mkdir() - # Header but no bullets — same shape as fixtures/invalid_content/3003.rst. - (fragments / "1234.rst").write_text("Added\n^^^^^\n\n", encoding="utf-8") - - with pytest.raises(ValueError, match="failed content validation"): - pkg.compile(fragments_dir=fragments, dry_run=True) diff --git a/tools/changelog/towncrier.toml b/tools/changelog/towncrier.toml new file mode 100644 index 000000000000..17d6e5c1ad52 --- /dev/null +++ b/tools/changelog/towncrier.toml @@ -0,0 +1,31 @@ +# 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 + +# Shared by every package: ``cli.py`` passes ``--dir source/``, so the paths below +# resolve per package (``template`` too, hence the ``../../``). +[tool.towncrier] +directory = "changelog.d" +filename = "docs/CHANGELOG.rst" +title_format = "{version} ({project_date})" +underlines = ["~", "^"] +template = "../../tools/changelog/towncrier_template.rst" +# Fragments carry their own ``* `` bullets, possibly several, and are copied verbatim. +all_bullets = false +wrap = false +ignore = [".gitkeep"] +# ``directory`` is the type word in a fragment's name (``.added.rst``), lowercase by towncrier +# convention; ``name`` is the CHANGELOG.rst heading its bullets are written under. +type = [ + { directory = "added", name = "Added", showcontent = true }, + { directory = "changed", name = "Changed", showcontent = true }, + { directory = "deprecated", name = "Deprecated", showcontent = true }, + { directory = "removed", name = "Removed", showcontent = true }, + { directory = "fixed", name = "Fixed", showcontent = true }, + # Hidden by the template: ``.skip`` opts a change out, ``.minor``/``.major`` + # raise the version bump above patch. + { directory = "skip", name = "Skip", showcontent = false }, + { directory = "minor", name = "Minor", showcontent = false, check = false }, + { directory = "major", name = "Major", showcontent = false, check = false }, +] diff --git a/tools/changelog/towncrier_template.rst b/tools/changelog/towncrier_template.rst new file mode 100644 index 000000000000..0ff7cc9f81ab --- /dev/null +++ b/tools/changelog/towncrier_template.rst @@ -0,0 +1,15 @@ +{# Emits fragment bodies verbatim under ``^``-underlined type headings and hides the skip and + tier types. The final expression adds the second blank line kept before each older version. #} + +{% for section, _ in sections.items() %} +{% for category, val in definitions.items() if category in sections[section] and definitions[category]['showcontent'] %} +{{ definitions[category]['name'] }} +{{ underlines[0] * definitions[category]['name']|length }} + +{% for text, values in sections[section][category].items() %} +{{ text }} +{% endfor %} + +{% endfor %} +{% endfor %} +{{ "\n" }}