diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index aa51259a90b..85d2c90144e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,3 +11,4 @@ closes - [ ] New or existing tests cover these changes. - [ ] The documentation is up to date with these changes. +- [ ] A release note is added (or the `skip-release-note` label is applied if none is needed). diff --git a/.github/RELEASE-core.md b/.github/RELEASE-core.md index 0f33dfe44f3..893a22e85a5 100644 --- a/.github/RELEASE-core.md +++ b/.github/RELEASE-core.md @@ -101,11 +101,32 @@ functions, classes, or features were added and whether they have complete docstrings. Add or edit docstrings as needed — touching docstrings and type annotations in code is OK during code freeze. -Write the release notes in `cuda_core/docs/source/release/`. Look at -historical release notes for guidance on format and structure. Balance all -entries for length, specificity, tone, and consistency. Highlight a few -notable items in the highlights section, keeping their full entries in the -appropriate sections below. +Release notes are not written at release time. Since 1.3.0 they are +per-change note files, added by each PR under `cuda_core/releasenotes/` (see "Release notes" in +[CONTRIBUTING.md](../CONTRIBUTING.md#release-notes)). The docs build generates +the page for each release from the notes in git: a minor release lists the +notes added since the previous minor release, and a patch release lists those +added since the previous release of the same `X.Y` line. Earlier releases keep +their hand-written pages in `cuda_core/docs/source/release/`. + +So the work here is to review and polish the notes, and to do it *before* the +tag is cut, because the page is built from the tagged commit: + +- Read the "In development" page of the latest docs, which shows the notes that + will go into the next release (or build the docs locally from a full clone). + Balance all entries for length, specificity, tone, and consistency, and edit + the note files in a PR. Edits to notes that already shipped in an earlier + release also change that release's page the next time the docs are published + from `main`, so older notes can be corrected too. +- Highlight a few notable items by adding a note with a `prelude` section. All + preludes in the release are combined at the top of its page, so the full + entries stay in their own sections below. +- Check the known issues (`issues` entries). They are listed on every release + until their entry is deleted, so delete the ones that this release resolves + and make sure the remaining ones are still true. +- Make sure the notes make it to `main`. The release workflow fails if no note + was added in the release's range, but that does not mean every PR had one + (the PR check allows a `skip-release-note` label); skim the list of PRs. --- @@ -126,6 +147,8 @@ certain the tag points to the correct commit before pushing. Tags should be GPG-signed. The tag name format is `cuda-core-v` (e.g. `cuda-core-v0.6.0`). The tag must point to a commit on `main`. +All release notes and docstring edits must already be merged, because the +release notes page is generated from the tagged commit. ```bash git checkout main diff --git a/.github/actions/fetch-release-history/action.yml b/.github/actions/fetch-release-history/action.yml new file mode 100644 index 00000000000..a5c1841f3d4 --- /dev/null +++ b/.github/actions/fetch-release-history/action.yml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +name: Fetch release history + +description: >- + Complete a shallow checkout with the git history needed to build release + notes (see cuda_python/docs/exts/release_notes.py): the history of the + checked-out commit and of every tag. No branches are fetched, which also + leaves out the large gh-pages branch that holds only built docs. + +runs: + using: composite + steps: + - name: Fetch the checked-out commit's history and all tags + shell: bash --noprofile --norc -xeuo pipefail {0} + run: | + # Run after actions/checkout with its default fetch-depth of 1. + # Release notes are read from the history reachable from release tags + # (patch tags may sit on maintenance branches) and from the commit being + # built, so no branch names are needed. + refspecs=("$(git rev-parse HEAD)" '+refs/tags/*:refs/tags/*') + if [[ "$(git rev-parse --is-shallow-repository)" == "true" ]]; then + git fetch --unshallow --no-tags origin "${refspecs[@]}" + else + git fetch --no-tags origin "${refspecs[@]}" + fi diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 52ee9698ab0..282ef91dd48 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -64,6 +64,10 @@ jobs: fetch-depth: 1 ref: ${{ inputs.git-tag }} + # The release notes are built from the git history between release tags. + - name: Fetch release history + uses: ./.github/actions/fetch-release-history + - name: Read build CTK version run: | if [[ -f ci/versions.yml ]]; then diff --git a/.github/workflows/ci-nightly.yml b/.github/workflows/ci-nightly.yml index a3179c1e155..b2a0071ea2e 100644 --- a/.github/workflows/ci-nightly.yml +++ b/.github/workflows/ci-nightly.yml @@ -44,7 +44,7 @@ jobs: - name: Run CI tools unit tests run: | - python -m pip install pytest + python -m pip install -r ci/tools/tests/requirements.txt # Standalone CI tool tests; skip repo-root conftest.py (imports cuda.pathfinder). python -m pytest -v --noconftest ci/tools/tests diff --git a/.github/workflows/pr-metadata-check.yml b/.github/workflows/pr-metadata-check.yml index 374af48abc6..d605292987f 100644 --- a/.github/workflows/pr-metadata-check.yml +++ b/.github/workflows/pr-metadata-check.yml @@ -18,6 +18,7 @@ on: - ready_for_review permissions: + contents: read pull-requests: read jobs: @@ -26,7 +27,17 @@ jobs: if: github.repository_owner == 'NVIDIA' runs-on: ubuntu-latest steps: - - name: Check metadata and blocking labels + # pull_request_target checks out the base branch, never the PR's code, so + # the scripts that run below are the trusted ones. + - name: Checkout check scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + ci/tools + cuda_python/docs/exts + persist-credentials: false + + - name: Check metadata, blocking labels, and release notes env: PR_URL: ${{ github.event.pull_request.html_url }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -72,14 +83,27 @@ jobs: ERRORS="${ERRORS}- **Blocked label detected**: label \`$label\` prevents merging. Remove it when the PR is ready.\n" done <<<"$BLOCKED_LABELS" + # Release notes are required for every PR that changes package sources. + set +e + RELEASE_NOTE_PROBLEMS=$(python3 ci/tools/check_pr_release_notes.py \ + --repo "$GH_REPO" --pr "$PR_NUMBER" --labels-json "$LABELS") + RELEASE_NOTE_STATUS=$? + set -e + if [ "$RELEASE_NOTE_STATUS" -eq 3 ]; then + ERRORS="${ERRORS}${RELEASE_NOTE_PROBLEMS}\n" + elif [ "$RELEASE_NOTE_STATUS" -ne 0 ]; then + echo "::error::The release-note check itself failed (exit status ${RELEASE_NOTE_STATUS})." + exit "$RELEASE_NOTE_STATUS" + fi + # Bots cannot provide positive metadata such as an assignee or - # milestone. Once blocking labels have been checked, exempt them - # from the remaining requirements. + # milestone. Once blocking labels and release notes have been + # checked, exempt them from the remaining requirements. if [ "$IS_BOT" = "true" ]; then if [ -n "$ERRORS" ]; then - echo "::error::This PR has a label that prevents merging. See the job summary for details." + echo "::error::This PR has a label that prevents merging or is missing a release note. See the job summary for details." { - echo "## PR Blocking Label Check Failed" + echo "## PR Check Failed" echo "" printf '%b' "$ERRORS" echo "" @@ -89,7 +113,7 @@ jobs: fi { - echo "## PR Blocking Label Check Passed" + echo "## PR Blocking Label and Release Note Check Passed" echo "" echo "Bot-authored PR; positive metadata requirements are exempt." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-cuda-pathfinder.yml b/.github/workflows/release-cuda-pathfinder.yml index 283ea78b603..e6e3700086e 100644 --- a/.github/workflows/release-cuda-pathfinder.yml +++ b/.github/workflows/release-cuda-pathfinder.yml @@ -68,16 +68,21 @@ jobs: # Resolve only the exact tag ref; checkout fails if the tag does not exist. ref: refs/tags/${{ steps.vars.outputs.tag }} + # The release notes are found from the git history between release tags. + - name: Fetch release history + uses: ./.github/actions/fetch-release-history + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + # Hand-written notes (before 1.9.0) or note files added in the release's range. - name: Check release notes exist env: - VERSION: ${{ steps.vars.outputs.version }} + TAG: ${{ steps.vars.outputs.tag }} run: | - notes="cuda_pathfinder/docs/source/release/${VERSION}-notes.rst" - if [[ ! -f "${notes}" ]]; then - echo "::error::Release notes not found: ${notes}" - echo "Create the release notes file before running this workflow." - exit 1 - fi + python ci/tools/check_release_notes.py --git-tag "${TAG}" --component cuda-pathfinder - name: Detect CI run ID id: detect-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e915e02093..15f87474a76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,6 +164,10 @@ jobs: with: ref: ${{ inputs.git-tag }} + # The release notes are found from the git history between release tags. + - name: Fetch release history + uses: ./.github/actions/fetch-release-history + - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf869bada02..e34c3297c4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,14 @@ repos: files: '^(ci/versions\.yml|cuda_bindings/pixi\.toml|cuda_core/pixi\.toml)$' pass_filenames: false + - id: release-notes-lint + name: Lint release notes + entry: python ./ci/tools/lint_release_notes.py + language: python + additional_dependencies: [pyyaml==6.0.3] + files: '^(cuda_bindings|cuda_core|cuda_pathfinder|cuda_python)/releasenotes/' + pass_filenames: false + - id: check-mempool-hygiene name: Check tests do not create uncapped memory pools entry: python ./ci/tools/check_mempool_hygiene.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b7b28d475e..199d10dd017 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,12 @@ Thank you for your interest in contributing to CUDA Python! Based on the type of - [Pre-commit lychee workaround](#pre-commit-lychee-workaround) - [Type stubs for cuda.core](#type-stubs-for-cudacore) - [Pre-commit](#pre-commit) + - [Release notes](#release-notes) + - [Writing a release note](#writing-a-release-note) + - [Known issues](#known-issues) + - [How release notes are assembled](#how-release-notes-are-assembled) - [Pixi lockfiles](#pixi-lockfiles) + - [Secret Scanning](#secret-scanning) - [Signing Your Work](#signing-your-work) - [Code signing](#code-signing) - [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco) @@ -234,6 +239,104 @@ warning reminding you to run `pre-commit install`. Windows contributors: see [Pre-commit lychee workaround](#pre-commit-lychee-workaround) under Development on Windows. +## Release notes + +Release notes are one small YAML file per change, kept next to the code in the +PR that makes the change. (The design is inspired by +[reno](https://docs.openstack.org/reno/latest/), but behaves quite differently.) +All four packages work this way: `cuda-bindings` and +`cuda-python` (13.5.0 and later), `cuda-core` (1.3.0 and later) and +`cuda-pathfinder` (1.9.0 and later), with notes in +`/releasenotes/`. Earlier releases keep their hand-written pages +under `/docs/source/release/`. + +### Writing a release note + +Every pull request that changes the sources of one of these packages needs a +release note for it, or an edit to an existing one (a PR touching multiple +packages needs one for each). Changes to docs, tests, examples, and CI do not +need one. A CI check enforces this. If a PR touches the sources but truly needs +no note, a maintainer can apply the `skip-release-note` label. + +Create a note with the `add_note.py` script. It can be run from any directory, +and takes the package and a few words describing the change: + +```bash +python toolshed/add_note.py cuda-core "Fix the thing" [--edit] +``` + +The package is one of `cuda-bindings`, `cuda-core`, `cuda-pathfinder` and +`cuda-python` (the `cuda-` prefix is optional). The new file is created in +`/releasenotes/`. `--edit` opens it in `$VISUAL` or `$EDITOR`. + +Edit the new file: keep the sections that apply and delete the rest, and replace +the `TODO` text. Each entry is reStructuredText, written for users of the +package. A pre-commit hook checks the notes for obvious mistakes, such as a +misspelled section or leftover `TODO` text. + +| Section | Use for | +|---|---| +| `features` | New functionality, including experimental APIs (say so in the text) | +| `upgrade` | Changes users may need to act on, including behavior changes | +| `deprecations` | Newly deprecated functionality | +| `critical` | Critical issues users must know about | +| `security` | Security issues | +| `fixes` | Bug fixes | +| `issues` | Known issues (see below) | +| `other` | Anything else | +| `prelude` | An introduction to a release's notes. Useful to highlight important themes in a release. | + +Some details: + +* You do not need to add a link to the pull request: one is added automatically, + taken from the `(#N)` in the squash-merge commit that introduced the note. +* To name the pull request(s) yourself instead, write `(#1234)` or + `(#1234, #1235)` anywhere in an entry, usually at the end. Each marker is + replaced with links, and the automatic lookup is skipped for that entry. This + is rarely needed. It is for a note whose file is added by a different PR than + the one that made the change, for example a note written afterwards or notes + moved in from another place. The marker can go anywhere in ordinary text. A + `(#N)` inside backquoted text, such as an inline literal, is left alone, so + it can be used in an example. Do not put a marker inside a literal block, or + the rows of a table or other directive content, where the link text could + break the markup. +* Do not rename or move a note after it is merged. +* Which releases list a note is decided from git, so a note shows up in a local + docs build only once it is committed. +* You can fix a note that is already part of a release (a typo, or a clearer + wording). The docs are published from `main`, and every page shows a note's + current text, so the fix appears on the pages of the releases that list it + the next time the docs are published. Editing a note never moves it to + another release. + +### Known issues + +Unlike the other sections, which appear only on the release that first contains +them, known issues are listed on **every** release until they are resolved. To +record one, add an `issues` entry to a note. To resolve it, delete the entry (or +the whole note, if it has nothing else) in the PR that fixes the problem, and +describe the fix in a new `fixes` entry. The pages of releases that were already +tagged keep listing the issue, with the text it had when its note was deleted. + +### How release notes are assembled + +The docs build generates a page for every release from the notes in git, +with a final "In development" page for notes that are not in any release yet. +Only plain `vX.Y.Z` tags count as releases. Pre-release tags (`rc`, `a`, `b`) +are ignored, and their notes roll into the final release. + +* A minor release (`X.Y.0`) lists the notes added since the previous minor + release. +* A patch release lists the notes added since the previous release of the same + `X.Y` line. +* A note that is backported appears on the patch release page and again on the + page of the next minor release that contains it. This is intentional. + +Building the docs needs the history of the checked-out commit and of every release +tag (no branches), and the build fails on a shallow clone rather than produce incomplete +pages. In CI, the `.github/actions/fetch-release-history` action does this. The +rules are documented in `cuda_python/docs/exts/release_notes.py`. + ## Pixi lockfiles The repository checks in a `pixi.lock` next to each `pixi.toml`. Those lockfiles diff --git a/ci/tools/check_pr_release_notes.py b/ci/tools/check_pr_release_notes.py new file mode 100644 index 00000000000..a898f41c801 --- /dev/null +++ b/ci/tools/check_pr_release_notes.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Check that a pull request that changes a package's sources adds or edits a release note. + +Usage: + python check_pr_release_notes.py --repo OWNER/REPO --pr NUMBER --labels-json '[{"name": ...}]' + +The changed files are listed with the ``gh`` CLI, which needs ``GH_TOKEN``, as +JSON records (path, status, and the old path of a rename) so that unusual file +names cannot be mistaken for other paths. Packages are those in +``release_ranges.PACKAGES``; a PR needs a note for each package whose +``source_paths`` it touches (as a new or an old path), unless it carries the +``skip-release-note`` label. Anything outside ``source_paths`` (docs, tests, +CI, pixi files, ...) never needs a note. A note is a file named like ``release_ranges.NOTE_NAME_RE`` +in the package's ``releasenotes/`` that the PR adds, modifies or renames to; +deleting or renaming one away does not count. + +Prints one markdown bullet per problem to stdout and exits with status 3 if +there are any. Failing to list the files prints the reason to stderr and exits +with status 2, so that callers can tell problems from a crash. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import NamedTuple + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "cuda_python" / "docs" / "exts")) +import release_ranges + +SKIP_LABEL = "skip-release-note" + +# `gh pr view --json files` would stop at 100 files; the files API pages through all of them. +# One compact JSON object per line: newlines in a file name are escaped in JSON, so they cannot +# split a record. +CHANGED_FILES_JQ = ".[] | {filename, status, previous_filename} | tojson" +EXIT_PROBLEMS = 3 +EXIT_GH_FAILED = 2 + +# Statuses of the files API in which the file exists afterwards. +EXISTING_STATUSES = frozenset({"added", "modified", "renamed", "copied", "changed"}) + + +class ChangedFile(NamedTuple): + filename: str + status: str + previous_filename: str | None = None + + +class GhError(RuntimeError): + """The list of changed files could not be obtained.""" + + +def _is_source(path: str, package: release_ranges.NotesPackage) -> bool: + for source in package.source_paths: + if source.endswith("/"): + if path.startswith(source): + return True + elif path == source: + return True + return False + + +def touches_sources(changed: list[ChangedFile], package: release_ranges.NotesPackage) -> bool: + # A file renamed out of the sources changes them as much as one renamed into them. + for file in changed: + paths = [file.filename] + if file.previous_filename is not None: + paths.append(file.previous_filename) + if any(_is_source(path, package) for path in paths): + return True + return False + + +def has_note(changed: list[ChangedFile], package: release_ranges.NotesPackage) -> bool: + notes_prefix = f"{package.notes_dir}/" + return any( + file.status in EXISTING_STATUSES + and file.filename.startswith(notes_prefix) + and release_ranges.NOTE_NAME_RE.fullmatch(file.filename[len(notes_prefix) :]) + for file in changed + ) + + +def find_problems(changed: list[ChangedFile], labels: list[str]) -> list[str]: + if SKIP_LABEL in (label.lower() for label in labels): + return [] + problems = [] + for component, package in release_ranges.PACKAGES.items(): + if touches_sources(changed, package) and not has_note(changed, package): + problems.append( + f"**Missing release note** for `{component}`: this PR changes its sources, so add or edit a " + f"note in `{package.notes_dir}/` " + f'(see "Release notes" in CONTRIBUTING.md), ' + f"or apply the `{SKIP_LABEL}` label if none is needed." + ) + return problems + + +def changed_files(repo: str, pr_number: str) -> list[ChangedFile]: + """List the files a PR changes, with their status and the old path of renamed files.""" + cmd = ["gh", "api", "--paginate", f"repos/{repo}/pulls/{pr_number}/files", "--jq", CHANGED_FILES_JQ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603 + except OSError as e: + raise GhError(f"could not run `gh`: {e}") from e + if result.returncode != 0: + raise GhError(f"`gh api` failed with status {result.returncode}:\n{result.stderr.strip()}") + files = [] + for line in result.stdout.splitlines(): + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError as e: + raise GhError(f"unexpected output from `gh api`: {line!r}") from e + files.append(ChangedFile(record["filename"], record["status"], record.get("previous_filename"))) + return files + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", required=True, help="OWNER/REPO the PR belongs to") + parser.add_argument("--pr", required=True, help="the PR number") + parser.add_argument("--labels-json", required=True, help="the PR's labels as a JSON list of objects with a name") + args = parser.parse_args(argv) + + labels = [label["name"] for label in json.loads(args.labels_json)] + try: + files = changed_files(args.repo, args.pr) + except GhError as e: + print(f"ERROR: could not list the files of PR #{args.pr} in {args.repo}: {e}", file=sys.stderr) + return EXIT_GH_FAILED + problems = find_problems(files, labels) + for problem in problems: + print(f"- {problem}") + if problems: + return EXIT_PROBLEMS + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py index 1c99ddb019a..be9c54994f9 100644 --- a/ci/tools/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -3,6 +3,11 @@ """Check that versioned release-notes files exist before releasing. +Releases of components listed in ``release_ranges.PACKAGES`` (at or after their +first version with note files) are checked differently: at least one note must have +been added in the release's range (see ``release_notes`` in +``cuda_python/docs/exts``); the release tag must be in a full clone. + Usage: python check_release_notes.py --git-tag --component @@ -17,9 +22,14 @@ import argparse import os import re +import subprocess import sys from pathlib import Path +# The release-notes rules are shared with the docs build. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "cuda_python" / "docs" / "exts")) +import release_ranges + COMPONENT_TO_PACKAGE: dict[str, str] = { "cuda-core": "cuda_core", "cuda-bindings": "cuda_bindings", @@ -85,6 +95,69 @@ def is_backport_version(version: str, backport_branch: str) -> bool: return version == backport_branch +_BASE_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)") + + +def notes_package(component: str, version: str) -> release_ranges.NotesPackage | None: + """Return the notes settings if *version* of *component* has note files.""" + package = release_ranges.PACKAGES.get(component) + if package is None: + return None + m = _BASE_VERSION_RE.match(version) + if m is None: + return None + if tuple(int(part) for part in m.groups()) < package.first_version: + return None + return package + + +def _git(repo_root: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", *args], # noqa: S607 + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"`git {' '.join(args)}` failed: {result.stderr.strip()}") + return result.stdout + + +def check_notes_in_range( + git_tag: str, package: release_ranges.NotesPackage, repo_root: Path = Path(".") +) -> list[tuple[str | Path, str]]: + """Return problems if no note was added in *git_tag*'s release range. + + The range is the one the docs use (see ``release_ranges.previous_release``). + Pre-release tags need no notes; theirs roll into the final release. + """ + notes_path = package.notes_dir + if _git(repo_root, "rev-parse", "--is-shallow-repository").strip() == "true": + return [("", "shallow clone: fetch the full history and tags (fetch-depth: 0)")] + + parsed = release_ranges.parse_release_tags([git_tag], package.tag_prefix) + if not parsed: + return [] # a pre-release tag + (version,) = parsed + releases = release_ranges.parse_release_tags(_git(repo_root, "tag", "--list").split(), package.tag_prefix) + if version not in releases: + return [("", f"tag {git_tag} not found in the clone; fetch tags")] + notes_releases = sorted(v for v in releases if v >= package.first_version) + previous = release_ranges.previous_release(version, notes_releases) + + if previous is None: + added = _git(repo_root, "ls-tree", "-r", "--name-only", git_tag, "--", notes_path) + range_text = "in the tree" + else: + previous_tag = releases[previous] + added = _git(repo_root, "diff", "--name-only", "--diff-filter=A", previous_tag, git_tag, "--", notes_path) + range_text = f"since {previous_tag}" + if not added.strip(): + return [(Path(notes_path), f"no release notes added {range_text}")] + return [] + + def notes_path(package: str, version: str) -> Path: return Path(package, "docs", "source", "release", f"{version}-notes.rst") @@ -108,6 +181,10 @@ def check_release_notes(git_tag: str, component: str, repo_root: Path = Path("." if is_post_release(version): return [] + package = notes_package(component, version) + if package is not None: + return check_notes_in_range(git_tag, package, repo_root) + path = notes_path(COMPONENT_TO_PACKAGE[component], version) full = repo_root / path if not full.is_file(): @@ -199,6 +276,12 @@ def validate_backport_decision( ) return 2, [] + if notes_package(component, backport_version) is not None: + # Patch notes are note files on the maintenance branch, collected at the + # backport tag; there is nothing to prepare on this branch. + print(f"Backport release notes for {decision} come from note files at that tag, skipping check.") + return None, [] + problems = check_release_notes(decision, component, repo_root) if problems: return 1, problems diff --git a/ci/tools/lint_release_notes.py b/ci/tools/lint_release_notes.py new file mode 100644 index 00000000000..2bc0373403d --- /dev/null +++ b/ci/tools/lint_release_notes.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyyaml"] +# /// + +"""Check the release note files of every package in ``release_ranges.PACKAGES`` for obvious mistakes. + +Run from anywhere; it checks this checkout. Each file in a package's ``releasenotes`` directory must: + +* be named like the files ``toolshed/add_note.py`` creates (``short-description-<16 hex digits>.yaml``); +* be a YAML mapping of known section keys (``release_ranges.SECTIONS`` and ``prelude``) to a + non-empty list of non-empty entries (``prelude`` may also be a single string); +* spell each section key plainly (``fixes:`` at the start of a line, no quotes), which is how the docs find them; +* not still contain the ``TODO`` text of the template. + +Prints one line per problem and exits with status 1 if there are any. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "cuda_python" / "docs" / "exts")) +import release_ranges # noqa: E402 + +KNOWN_KEYS = {release_ranges.PRELUDE, *(key for key, _title in release_ranges.SECTIONS)} + + +def lint_note(path: Path) -> list[str]: + """Return the problems of one note file, as messages without the file name.""" + if not release_ranges.NOTE_NAME_RE.fullmatch(path.name): + return ["not named like a note (short-description-<16 hex digits>.yaml); only notes belong here"] + try: + text = path.read_text(encoding="utf-8") + data = yaml.safe_load(text) + except (yaml.YAMLError, UnicodeDecodeError) as e: + return [f"not valid YAML: {e}"] + if not isinstance(data, dict) or not data: + return ["must be a YAML mapping of sections to entries, with at least one section"] + + problems = [] + for key, value in data.items(): + if key not in KNOWN_KEYS: + problems.append(f"unknown section {key!r}; use one of: {', '.join(sorted(KNOWN_KEYS))}") + continue + if not re.search(release_ranges.section_key_re(key), text, re.MULTILINE): + problems.append(f"write the {key!r} key as plain `{key}:` at the start of a line (no quotes or spaces)") + continue + entries = value + if isinstance(value, str): + if key != release_ranges.PRELUDE: + problems.append(f"section {key!r} must be a list of entries") + continue + entries = [value] + if not isinstance(entries, list) or not entries: + problems.append(f"section {key!r} must be a non-empty list of entries (delete it if unused)") + continue + for entry in entries: + if not isinstance(entry, str) or not entry.strip(): + problems.append(f"section {key!r} has an entry that is not text") + elif entry.lstrip().startswith("TODO"): + problems.append(f"section {key!r} still has the template's TODO text") + return problems + + +def lint_package(repo_root: Path, package: release_ranges.NotesPackage) -> list[tuple[Path, str]]: + notes_dir = repo_root / package.notes_dir + if not notes_dir.is_dir(): + return [] + problems = [] + for path in sorted(notes_dir.iterdir()): + for message in lint_note(path): + problems.append((path, message)) + return problems + + +def main() -> int: + failed = False + for package in release_ranges.PACKAGES.values(): + for path, message in lint_package(REPO_ROOT, package): + print(f"{path.relative_to(REPO_ROOT).as_posix()}: {message}", file=sys.stderr) + failed = True + if failed: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/tools/tests/requirements.txt b/ci/tools/tests/requirements.txt new file mode 100644 index 00000000000..5ca9fd9cf81 --- /dev/null +++ b/ci/tools/tests/requirements.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Dependencies for the tests in this directory: +# +# python -m pip install -r ci/tools/tests/requirements.txt +# python -m pytest --noconftest ci/tools/tests +# +# (--noconftest skips the repo-root conftest.py, which imports cuda.pathfinder.) + +pytest + +# The release-notes tests run the docs extension in cuda_python/docs/exts. +# Keep sphinx in sync with cuda_python/docs/environment-docs.yml. +sphinx<8.2.0 +pyyaml diff --git a/ci/tools/tests/test_add_note.py b/ci/tools/tests/test_add_note.py new file mode 100644 index 00000000000..ad552d544a5 --- /dev/null +++ b/ci/tools/tests/test_add_note.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "toolshed")) +sys.path.insert(0, str(REPO_ROOT / "cuda_python" / "docs" / "exts")) +import add_note +import release_ranges + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_the_template_has_exactly_the_sections_in_order(): + keys = re.findall(r"^([a-z]+):$", add_note.TEMPLATE, flags=re.MULTILINE) + assert keys == [key for key, _title in release_ranges.SECTIONS if key != "issues"] + ["issues"] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_the_template_is_a_note_with_only_todo_entries(): + data = yaml.safe_load(add_note.TEMPLATE) + assert set(data) == {key for key, _title in release_ranges.SECTIONS} + assert all(entry.startswith("TODO") for entries in data.values() for entry in entries) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + ("name", "component"), + [ + ("cuda-core", "cuda-core"), + ("cuda_core", "cuda-core"), + ("core", "cuda-core"), + ("Bindings", "cuda-bindings"), + ("cuda_pathfinder", "cuda-pathfinder"), + ("python", "cuda-python"), + ], +) +def test_package_names(name, component): + assert add_note.find_package(name) == component + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_an_unknown_package_lists_the_choices(): + with pytest.raises(ValueError, match="cuda-bindings, cuda-core, cuda-pathfinder, cuda-python"): + add_note.find_package("numpy") + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + ("description", "slug"), + [ + ("Fix the Foo (bar) crash!", "fix-the-foo-bar-crash"), + (" leading and trailing ", "leading-and-trailing"), + ("x" * 100, "x" * add_note.MAX_SLUG_LENGTH), + ("Ünïcode and symbols #1", "n-code-and-symbols-1"), + ], +) +def test_slugify(description, slug): + assert add_note.slugify(description) == slug + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("description", ["", " ", "!!!"]) +def test_a_description_without_letters_or_digits_is_an_error(description): + with pytest.raises(ValueError, match="no letters or digits"): + add_note.slugify(description) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_create_note_makes_a_valid_file_in_the_packages_directory(tmp_path): + path = add_note.create_note("core", "Fix the thing", repo_root=tmp_path) + assert path.parent == tmp_path / release_ranges.PACKAGES["cuda-core"].notes_dir + assert release_ranges.NOTE_NAME_RE.fullmatch(path.name) + assert path.name.startswith("fix-the-thing-") + text = path.read_text(encoding="utf-8") + assert text.startswith("# SPDX-FileCopyrightText: Copyright (c) 2") + assert "{year}" not in text + # Two notes with the same description do not collide. + assert add_note.create_note("core", "Fix the thing", repo_root=tmp_path) != path + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_main_works_from_any_directory_and_reports_errors(monkeypatch, tmp_path, capsys): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(add_note, "REPO_ROOT", tmp_path) + assert add_note.main(["cuda-pathfinder", "A new note"]) == 0 + out = capsys.readouterr().out + assert out.startswith("Created cuda_pathfinder/releasenotes/a-new-note-") + assert len(list((tmp_path / "cuda_pathfinder" / "releasenotes").glob("*.yaml"))) == 1 + + assert add_note.main(["core", "!!!"]) == 2 + assert "no letters or digits" in capsys.readouterr().err + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_edit_runs_the_editor_on_the_new_file(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(add_note, "REPO_ROOT", tmp_path) + monkeypatch.setattr(add_note.subprocess, "run", lambda cmd, **_kwargs: calls.append(cmd) or _Done()) + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", "myeditor --wait") + assert add_note.main(["core", "Edit me", "--edit"]) == 0 + assert calls[0][:2] == ["myeditor", "--wait"] + assert calls[0][2].endswith(".yaml") + + monkeypatch.delenv("EDITOR") + assert add_note.main(["core", "Edit me too", "--edit"]) == 2 + + +class _Done: + returncode = 0 + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_the_package_choices_come_from_the_packages_and_short_names_still_work(capsys): + with pytest.raises(SystemExit) as exit_info: + add_note.main(["numpy", "x"]) + assert exit_info.value.code == 2 + error = capsys.readouterr().err + assert "invalid choice: 'cuda-numpy'" in error + for component in release_ranges.PACKAGES: + assert component in error diff --git a/ci/tools/tests/test_check_pr_release_notes.py b/ci/tools/tests/test_check_pr_release_notes.py new file mode 100644 index 00000000000..86db2657e8a --- /dev/null +++ b/ci/tools/tests/test_check_pr_release_notes.py @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import check_pr_release_notes +from check_pr_release_notes import EXIT_GH_FAILED, EXIT_PROBLEMS, ChangedFile, changed_files, find_problems, main + +NOTE = "cuda_bindings/releasenotes/fix-thing-0123456789abcdef.yaml" +SOURCE = "cuda_bindings/cuda/bindings/driver.pyx" + + +def _files(*paths, status="modified"): + return [ChangedFile(path, status) for path in paths] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + "path", + [ + "cuda_bindings/cuda/bindings/driver.pyx", + "cuda_bindings/cuda/bindings/_internal/nvml_linux.pyx", + "cuda_bindings/pyproject.toml", + "cuda_bindings/setup.py", + "cuda_bindings/build_hooks.py", + "cuda_bindings/MANIFEST.in", + ], +) +def test_source_changes_need_a_note(path): + problems = find_problems(_files(path), []) + assert len(problems) == 1 + assert "cuda-bindings" in problems[0] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + "path", + [ + "cuda_bindings/docs/source/install.rst", + "cuda_bindings/tests/test_nvml.py", + "cuda_bindings/examples/basic.py", + "cuda_bindings/pixi.toml", + "cuda_bindings/pixi.lock", + "cuda_bindings/README.md", + "cuda_bindings/AGENTS.md", + "cuda_bindings/releasenotes/README.md", + "cuda_bindings/cudax/other.py", # not under cuda_bindings/cuda/ + ".github/workflows/ci.yml", + "ci/tools/check_pr_release_notes.py", + "cuda_core/tests/test_device.py", + "cuda_core/pixi.toml", + "cuda_pathfinder/tests/test_find.py", + "cuda_pathfinder/pixi.toml", + "cuda_pathfinder/docs/source/index.rst", + "cuda_python/docs/source/conf.py", + "cuda_python/README.md", + "pixi.toml", + ], +) +def test_other_changes_need_no_note(path): + assert find_problems(_files(path), []) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + "path", + ["cuda_core/cuda/core/_device.pyx", "cuda_core/pyproject.toml", "cuda_core/setup.py", "cuda_core/MANIFEST.in"], +) +def test_cuda_core_source_changes_need_a_core_note(path): + problems = find_problems(_files(path), []) + assert len(problems) == 1 + assert "cuda-core" in problems[0] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("path", ["cuda_pathfinder/cuda/pathfinder/__init__.py", "cuda_pathfinder/pyproject.toml"]) +def test_cuda_pathfinder_source_changes_need_a_pathfinder_note(path): + problems = find_problems(_files(path), []) + assert len(problems) == 1 + assert "cuda-pathfinder" in problems[0] + assert find_problems(_files(path, "cuda_pathfinder/releasenotes/x-0123456789abcdef.yaml"), []) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("path", ["cuda_python/setup.py", "cuda_python/pyproject.toml"]) +def test_cuda_python_source_changes_need_a_python_note(path): + problems = find_problems(_files(path), []) + assert len(problems) == 1 + assert "cuda-python" in problems[0] + assert find_problems(_files(path, "cuda_python/releasenotes/x-0123456789abcdef.yaml"), []) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_each_touched_package_needs_its_own_note(): + changed = _files(SOURCE, "cuda_core/cuda/core/_device.pyx", NOTE) + problems = find_problems(changed, []) + assert len(problems) == 1 + assert "cuda-core" in problems[0] + changed += _files("cuda_core/releasenotes/other-0123456789abcdef.yaml") + assert find_problems(changed, []) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("status", ["added", "modified", "renamed", "copied", "changed"]) +def test_adding_or_editing_a_note_satisfies_the_check(status): + assert find_problems([ChangedFile(SOURCE, "modified"), ChangedFile(NOTE, status)], ["bug"]) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_deleting_a_note_does_not_satisfy_the_check(): + changed = [ChangedFile(SOURCE, "modified"), ChangedFile(NOTE, "removed")] + assert len(find_problems(changed, [])) == 1 + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_renaming_a_note_away_does_not_satisfy_the_check(): + # The new path is outside notes/; the old path (previous_filename) is not a note either. + changed = [ChangedFile(SOURCE, "modified"), ChangedFile("docs/old-note.yaml", "renamed", NOTE)] + assert len(find_problems(changed, [])) == 1 + # Renaming one *into* notes/ is a note. + changed = [ChangedFile(SOURCE, "modified"), ChangedFile(NOTE, "renamed", "somewhere/else.yaml")] + assert find_problems(changed, []) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + "path", + [ + "cuda_bindings/releasenotes/.gitkeep", + "cuda_bindings/releasenotes/README.md", + "cuda_bindings/releasenotes/fix-0123456789abcdef.yaml.orig", + "cuda_bindings/releasenotes/config.yaml", + "cuda_bindings/releasenotes/fix.yaml", # not named like a note (no random suffix) + "cuda_bindings/releasenotes/sub/fix-0123456789abcdef.yaml", # not directly in the directory + "cuda_core/releasenotes/fix-0123456789abcdef.yaml", # another package's note + ], +) +def test_only_yaml_files_in_the_packages_notes_dir_are_notes(path): + assert len(find_problems(_files(SOURCE, path), [])) == 1 + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_renaming_a_source_file_out_of_the_sources_still_needs_a_note(): + changed = [ChangedFile("cuda_bindings/docs/driver.pyx", "renamed", SOURCE)] + assert len(find_problems(changed, [])) == 1 + assert find_problems([*changed, ChangedFile(NOTE, "added")], []) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_a_file_name_with_a_newline_cannot_fake_a_note(): + # A single file whose *name* contains a newline and a note path. Parsed as JSON records it is + # one source-like name, not two files. + fake = f"x\n{NOTE}" + assert len(find_problems([ChangedFile(SOURCE, "modified"), ChangedFile(fake, "added")], [])) == 1 + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_config_or_readme_in_the_notes_dir_is_not_a_note(): + changed = _files(SOURCE, "cuda_bindings/releasenotes/config.yaml") + assert len(find_problems(changed, [])) == 1 + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("label", ["skip-release-note", "Skip-Release-Note"]) +def test_skip_label(label): + assert find_problems(_files(SOURCE), ["bug", label]) == [] + + +def _labels(*names): + return json.dumps([{"name": name} for name in names]) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_main_exit_codes(monkeypatch, capsys): + calls = [] + + def fake_changed_files(repo, pr_number): + calls.append((repo, pr_number)) + return _files(SOURCE) + + monkeypatch.setattr(check_pr_release_notes, "changed_files", fake_changed_files) + + argv = ["--repo", "o/r", "--pr", "7"] + assert main([*argv, "--labels-json", _labels("bug")]) == EXIT_PROBLEMS + assert capsys.readouterr().out.startswith("- **Missing release note**") + assert main([*argv, "--labels-json", _labels("bug", "skip-release-note")]) == 0 + assert capsys.readouterr().out == "" + assert calls == [("o/r", "7"), ("o/r", "7")] + + monkeypatch.setattr(check_pr_release_notes, "changed_files", lambda *_args: _files("README.md")) + assert main([*argv, "--labels-json", "[]"]) == 0 + + +def _fake_gh(monkeypatch, *, stdout="", stderr="", returncode=0): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return subprocess.CompletedProcess(cmd, returncode, stdout=stdout, stderr=stderr) + + monkeypatch.setattr(subprocess, "run", fake_run) + return calls + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_changed_files_parses_json_records_one_per_line(monkeypatch): + tricky = f"x\n{NOTE}" + lines = [ + {"filename": "a.py", "status": "modified", "previous_filename": None}, + {"filename": "new/b.py", "status": "renamed", "previous_filename": "old/b.py"}, + {"filename": tricky, "status": "added", "previous_filename": None}, + ] + calls = _fake_gh(monkeypatch, stdout="\n".join(json.dumps(line) for line in lines) + "\n\n") + assert changed_files("o/r", "7") == [ + ChangedFile("a.py", "modified", None), + ChangedFile("new/b.py", "renamed", "old/b.py"), + ChangedFile(tricky, "added", None), + ] + cmd, kwargs = calls[0] + assert cmd[:4] == ["gh", "api", "--paginate", "repos/o/r/pulls/7/files"] + assert cmd[cmd.index("--jq") + 1] == check_pr_release_notes.CHANGED_FILES_JQ + assert kwargs["capture_output"] is True + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_changed_files_jq_emits_one_compact_json_object_per_file(): + # `jq` is not required by the tests; just pin down what is asked of gh. + assert "tojson" in check_pr_release_notes.CHANGED_FILES_JQ + assert "previous_filename" in check_pr_release_notes.CHANGED_FILES_JQ + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_gh_failure_reports_gh_stderr_and_exits_2(monkeypatch, capsys): + _fake_gh(monkeypatch, stderr="HTTP 404: Not Found (https://api.github.com/repos/o/r/pulls/7/files)", returncode=1) + assert main(["--repo", "o/r", "--pr", "7", "--labels-json", "[]"]) == EXIT_GH_FAILED + captured = capsys.readouterr() + assert captured.out == "" + assert "HTTP 404: Not Found" in captured.err + assert "PR #7 in o/r" in captured.err + assert "Traceback" not in captured.err + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_gh_missing_or_garbled_output_is_a_clean_error(monkeypatch, capsys): + def missing(cmd, **kwargs): + raise FileNotFoundError(2, "No such file or directory", "gh") + + monkeypatch.setattr(subprocess, "run", missing) + assert main(["--repo", "o/r", "--pr", "7", "--labels-json", "[]"]) == EXIT_GH_FAILED + assert "could not run `gh`" in capsys.readouterr().err + + _fake_gh(monkeypatch, stdout="not json\n") + assert main(["--repo", "o/r", "--pr", "7", "--labels-json", "[]"]) == EXIT_GH_FAILED + assert "unexpected output" in capsys.readouterr().err diff --git a/ci/tools/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py index e08eac6610d..0163c233ffe 100644 --- a/ci/tools/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -3,9 +3,12 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent)) from check_release_notes import ( check_release_notes, @@ -331,3 +334,132 @@ def test_mainline_bindings_rejects_non_backport_tag(self, tmp_path): ) assert rc == 2 + + +NOTES = "cuda_bindings/releasenotes" + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) # noqa: S603,S607 + + +def _release(repo: Path, tag: str, note: str | None) -> None: + """Commit an optional note file, then tag.""" + if note is not None: + path = repo / NOTES / f"{note}.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("---\nfixes:\n - x\n", encoding="utf-8") + else: + (repo / "unrelated.txt").write_text(tag, encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", tag) + _git(repo, "tag", tag) + + +@pytest.fixture +def notes_repo(tmp_path): + _git(tmp_path, "init", "-q", "-b", "main") + _git(tmp_path, "config", "user.email", "test@example.test") + _git(tmp_path, "config", "user.name", "Test") + _release(tmp_path, "v13.4.3", None) # before note files: still needs a hand-written page + return tmp_path + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +class TestReleaseNotes: + def test_first_notes_release_needs_a_note(self, notes_repo): + _release(notes_repo, "v13.5.0", None) + problems = check_release_notes("v13.5.0", "cuda-bindings", notes_repo) + assert [reason for _path, reason in problems] == ["no release notes added in the tree"] + + def test_first_notes_release_with_note(self, notes_repo): + _release(notes_repo, "v13.5.0", "a-0000000000000001") + assert check_release_notes("v13.5.0", "cuda-bindings", notes_repo) == [] + + def test_minor_release_without_new_note_fails(self, notes_repo): + _release(notes_repo, "v13.5.0", "a-0000000000000001") + _release(notes_repo, "v13.6.0", None) + problems = check_release_notes("v13.6.0", "cuda-bindings", notes_repo) + assert [reason for _path, reason in problems] == ["no release notes added since v13.5.0"] + + def test_minor_release_counts_notes_since_previous_minor(self, notes_repo): + _release(notes_repo, "v13.5.0", "a-0000000000000001") + _release(notes_repo, "v13.5.1", "b-0000000000000002") # added after 13.5.0, so part of 13.6.0's range + _release(notes_repo, "v13.6.0", None) + assert check_release_notes("v13.6.0", "cuda-bindings", notes_repo) == [] + + def test_minor_release_may_start_at_a_patch_number(self, notes_repo): + _release(notes_repo, "v13.5.0", "a-0000000000000001") + _release(notes_repo, "v13.6.0rc1", None) # 13.6.0 was only ever a pre-release + _release(notes_repo, "v13.6.1", "b-0000000000000002") + assert check_release_notes("v13.6.1", "cuda-bindings", notes_repo) == [] + _release(notes_repo, "v13.7.0", None) + problems = check_release_notes("v13.7.0", "cuda-bindings", notes_repo) + assert [reason for _path, reason in problems] == ["no release notes added since v13.6.1"] + + def test_patch_release_without_new_note_fails(self, notes_repo): + _release(notes_repo, "v13.5.0", "a-0000000000000001") + _release(notes_repo, "v13.5.1", None) + problems = check_release_notes("v13.5.1", "cuda-bindings", notes_repo) + assert [reason for _path, reason in problems] == ["no release notes added since v13.5.0"] + + def test_patch_release_with_new_note_passes(self, notes_repo): + _release(notes_repo, "v13.5.0", "a-0000000000000001") + _release(notes_repo, "v13.5.1", "b-0000000000000002") + assert check_release_notes("v13.5.1", "cuda-bindings", notes_repo) == [] + + def test_prerelease_tag_needs_no_note(self, notes_repo): + _release(notes_repo, "v13.5.0rc1", None) + assert check_release_notes("v13.5.0rc1", "cuda-bindings", notes_repo) == [] + + def test_missing_tag_is_reported(self, notes_repo): + problems = check_release_notes("v13.5.0", "cuda-bindings", notes_repo) + assert [path for path, _reason in problems] == [""] + + def test_shallow_clone_is_rejected(self, notes_repo, tmp_path_factory): + _release(notes_repo, "v13.5.0", "a-0000000000000001") + shallow = tmp_path_factory.mktemp("shallow") / "clone" + subprocess.run( # noqa: S603 + ["git", "clone", "-q", "--depth", "1", f"file://{notes_repo}", str(shallow)], # noqa: S607 + check=True, + ) + problems = check_release_notes("v13.5.0", "cuda-bindings", shallow) + assert [path for path, _reason in problems] == [""] + + def test_earlier_releases_still_need_the_handwritten_page(self, notes_repo): + problems = check_release_notes("v13.4.3", "cuda-bindings", notes_repo) + assert [reason for _path, reason in problems] == ["missing"] + + def test_other_components_are_unaffected(self, notes_repo): + problems = check_release_notes("cuda-core-v1.2.0", "cuda-core", notes_repo) + assert [reason for _path, reason in problems] == ["missing"] + + def test_cuda_core_uses_its_own_tag_namespace_and_notes_dir(self, notes_repo): + _release(notes_repo, "cuda-core-v1.2.1", None) # before note files: still needs a hand-written page + assert [r for _p, r in check_release_notes("cuda-core-v1.2.1", "cuda-core", notes_repo)] == ["missing"] + + _release(notes_repo, "cuda-core-v1.3.0", "a-0000000000000001") # a bindings note does not count + problems = check_release_notes("cuda-core-v1.3.0", "cuda-core", notes_repo) + assert [reason for _path, reason in problems] == ["no release notes added in the tree"] + + path = notes_repo / "cuda_core" / "releasenotes" / "b-0000000000000002.yaml" + path.parent.mkdir(parents=True) + path.write_text("---\nfixes:\n - x\n", encoding="utf-8") + _git(notes_repo, "add", "-A") + _git(notes_repo, "commit", "-q", "-m", "core note") + _git(notes_repo, "tag", "cuda-core-v1.3.1") + problems = check_release_notes("cuda-core-v1.3.1", "cuda-core", notes_repo) + assert problems == [] # the note was added after 1.3.0 + + _release(notes_repo, "cuda-core-v1.3.2", None) + problems = check_release_notes("cuda-core-v1.3.2", "cuda-core", notes_repo) + assert [reason for _path, reason in problems] == ["no release notes added since cuda-core-v1.3.1"] + + def test_cuda_pathfinder_cutover(self, notes_repo): + # 1.8.x (including patch releases) keeps hand-written pages; 1.9.0 and later need a note file. + for tag in ("cuda-pathfinder-v1.8.3", "cuda-pathfinder-v1.8.4"): + _release(notes_repo, tag, None) + assert [r for _p, r in check_release_notes(tag, "cuda-pathfinder", notes_repo)] == ["missing"] + _release(notes_repo, "cuda-pathfinder-v1.9.0", None) + problems = check_release_notes("cuda-pathfinder-v1.9.0", "cuda-pathfinder", notes_repo) + assert [reason for _path, reason in problems] == ["no release notes added in the tree"] diff --git a/ci/tools/tests/test_lint_release_notes.py b/ci/tools/tests/test_lint_release_notes.py new file mode 100644 index 00000000000..fd4b5719ff4 --- /dev/null +++ b/ci/tools/tests/test_lint_release_notes.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "ci" / "tools")) +sys.path.insert(0, str(REPO_ROOT / "toolshed")) +sys.path.insert(0, str(REPO_ROOT / "cuda_python" / "docs" / "exts")) +import add_note +import lint_release_notes +import release_ranges + +NAME = "fix-something-0123456789abcdef.yaml" + + +def _lint(tmp_path, text, name=NAME): + path = tmp_path / name + path.write_text(text, encoding="utf-8") + return lint_release_notes.lint_note(path) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + "text", + [ + "fixes:\n - A fix.\n", + "---\nfixes:\n - |\n A fix.\n Over two lines.\nissues:\n - A known issue.\n", + "prelude: >\n An introduction.\nfeatures:\n - A feature.\n", + "prelude:\n - An introduction.\n", + ], +) +def test_valid_notes_pass(tmp_path, text): + assert _lint(tmp_path, text) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + ("text", "message"), + [ + ("", "must be a YAML mapping"), + ("- fixes\n", "must be a YAML mapping"), + ("fixes: [unclosed\n", "not valid YAML"), + ("fix:\n - Typo in the section name.\n", "unknown section 'fix'"), + ("fixes: A bare string.\n", "must be a list of entries"), + ("fixes: []\n", "non-empty list"), + ("fixes:\n", "non-empty list"), + ("fixes:\n - ''\n", "not text"), + ("fixes:\n - 5\n", "not text"), + ("fixes:\n - TODO fill this in\n", "TODO"), + ("features:\n - A feature.\nfixes:\n - TODO fill this in\n", "'fixes' still has"), + ("fixes:\n - TODO: unquoted colon is a mapping\n", "not text"), + ('"fixes":\n - A fix.\n', "plain `fixes:`"), + ("'prelude': An introduction.\n", "plain `prelude:`"), + (" fixes:\n - Indented key.\n", "plain `fixes:`"), + ('"issues":\n - A known issue.\n', "plain `issues:`"), + ("'issues':\n - A known issue.\n", "plain `issues:`"), + ("issues :\n - A known issue.\n", "plain `issues:`"), + ], +) +def test_obvious_mistakes_are_reported(tmp_path, text, message): + problems = _lint(tmp_path, text) + assert len(problems) == 1 + assert message in problems[0] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + "name", + [ + "config.yaml", + "README.md", + ".gitkeep", + "fix.yaml", + "Fix-0123456789abcdef.yaml", + "fix-0123456789abcdef.yml", + "fix-xyz.yaml", + ], +) +def test_only_notes_belong_in_the_directory(tmp_path, name): + problems = _lint(tmp_path, "fixes:\n - A fix.\n", name=name) + assert len(problems) == 1 + assert "not named like a note" in problems[0] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_a_freshly_created_note_fails_until_it_is_filled_in(tmp_path): + path = add_note.create_note("bindings", "Something new", repo_root=tmp_path) + problems = lint_release_notes.lint_note(path) + assert len(problems) == len(release_ranges.SECTIONS) + assert all("TODO" in problem for problem in problems) + + path.write_text("---\nfixes:\n - Something was fixed.\n", encoding="utf-8") + assert lint_release_notes.lint_note(path) == [] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_lint_package_reports_every_file_and_ignores_a_missing_directory(tmp_path): + package = release_ranges.PACKAGES["cuda-core"] + assert lint_release_notes.lint_package(tmp_path, package) == [] + + notes_dir = tmp_path / package.notes_dir + notes_dir.mkdir(parents=True) + (notes_dir / NAME).write_text("fixes:\n - A fix.\n", encoding="utf-8") + (notes_dir / "config.yaml").write_text("sections: []\n", encoding="utf-8") + (notes_dir / "other-0123456789abcdef.yaml").write_text("oops: 1\n", encoding="utf-8") + problems = lint_release_notes.lint_package(tmp_path, package) + assert sorted(path.name for path, _message in problems) == ["config.yaml", "other-0123456789abcdef.yaml"] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_the_notes_in_this_checkout_are_clean(capsys): + assert lint_release_notes.main() == 0, capsys.readouterr().err diff --git a/ci/tools/tests/test_release_date.py b/ci/tools/tests/test_release_date.py new file mode 100644 index 00000000000..f22bd3920eb --- /dev/null +++ b/ci/tools/tests/test_release_date.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "cuda_python" / "docs" / "exts")) +import release_date + +PAGE = """.. Generated by release_notes; do not edit or commit. + +Package 1.0.0 +============= + +New Features +------------ + +- First entry. +""" + + +def _read(monkeypatch, docname, text, project="cuda.core", date="2026-03-06"): + monkeypatch.setattr(release_date, "_git_tag_date", lambda _tag: date) + source = [text] + release_date._on_source_read(types.SimpleNamespace(config=types.SimpleNamespace(project=project)), docname, source) + return source[0] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_date_is_added_after_the_title(monkeypatch): + result = _read(monkeypatch, "release/1.0.0-notes", PAGE) + assert "Package 1.0.0\n=============\n\nReleased on Mar 6, 2026\n\nNew Features\n" in result + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_words_released_on_elsewhere_in_the_page_do_not_suppress_the_date(monkeypatch): + text = PAGE + "\n- The feature was released on Tuesday.\n- Released on request.\n" + result = _read(monkeypatch, "release/1.0.0-notes", text) + assert "\nReleased on Mar 6, 2026\n" in result + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("existing", ["Released on Jan 1, 2026", "released on Jan 1, 2026"]) +def test_an_existing_date_line_after_the_title_is_kept(monkeypatch, existing): + text = PAGE.replace("=============\n\n", f"=============\n\n{existing}\n\n", 1) + assert _read(monkeypatch, "release/1.0.0-notes", text) == text + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_other_pages_unknown_projects_and_untagged_versions_are_left_alone(monkeypatch): + assert _read(monkeypatch, "install", PAGE) == PAGE + assert _read(monkeypatch, "release/1.0.0-notes", PAGE, project="Not A Project") == PAGE + assert _read(monkeypatch, "release/1.0.0-notes", PAGE, date="") == PAGE diff --git a/ci/tools/tests/test_release_notes.py b/ci/tools/tests/test_release_notes.py new file mode 100644 index 00000000000..60182e9b0ad --- /dev/null +++ b/ci/tools/tests/test_release_notes.py @@ -0,0 +1,580 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import shutil +import subprocess +import sys +import types +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "cuda_python" / "docs" / "exts")) +import release_notes +import release_ranges + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_SECTION_KEYS = {"features", "issues", "upgrade", "deprecations", "critical", "security", "fixes", "other"} +NOTES_DIR = "pkg/releasenotes" + +SECTIONS = [("features", "New Features"), ("fixes", "Bug Fixes"), ("issues", "Known Issues")] +PR_URL = "https://example.test/pull/{number}" + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_parse_release_tags_ignores_prereleases_and_other_namespaces(): + tags = ["v1.0.0", "v1.0.0rc1", "v1.0.0a0", "cuda-core-v1.2.0", "v1.0", "v1.0.1", "xv1.0.2"] + assert set(release_ranges.parse_release_tags(tags, "v")) == {(1, 0, 0), (1, 0, 1)} + assert set(release_ranges.parse_release_tags(tags, "cuda-core-v")) == {(1, 2, 0)} + + +def _parse(text): + return tuple(int(part) for part in text.split(".")) + + +CANDIDATES = [_parse(v) for v in ["13.5.0", "13.5.1", "13.5.2", "13.5.3", "13.6.0", "13.6.1", "14.0.0"]] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("13.5.0", None), + ("13.5.1", "13.5.0"), + ("13.5.3", "13.5.2"), + ("13.5.2", "13.5.1"), + ("13.6.0", "13.5.0"), # a minor release re-lists the previous line's patches + ("13.6.1", "13.6.0"), + ("14.0.0", "13.6.0"), + ], +) +def test_previous_release(version, expected): + result = release_ranges.previous_release(_parse(version), CANDIDATES) + if expected is None: + assert result is None + else: + assert result == _parse(expected) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_minor_release_may_start_at_a_patch_number(): + # 13.4.0 never existed (it was only a pre-release): 13.4.1 is the minor release of its line. + candidates = [_parse(v) for v in ["13.3.0", "13.3.1", "13.4.1", "13.4.2", "13.5.0", "13.5.1", "13.6.2"]] + assert release_ranges.previous_release((13, 4, 1), candidates) == (13, 3, 0) + assert release_ranges.previous_release((13, 4, 2), candidates) == (13, 4, 1) + assert release_ranges.previous_release((13, 5, 0), candidates) == (13, 4, 1) + assert release_ranges.previous_release((13, 6, 2), candidates) == (13, 5, 0) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("component", sorted(release_ranges.PACKAGES)) +def test_first_notes_version_is_a_minor_release(component): + # previous_release only sees releases from first_version on. If that were a patch release + # (X.Y.Z, Z > 0), it would be mistaken for the minor release of its line, and the next minor + # release would be made relative to it instead of to the earlier patch-free baseline. + first_version = release_ranges.PACKAGES[component].first_version + assert first_version[2] == 0, ( + f"{component}: first version with note files {release_ranges.format_version(first_version)} must be an X.Y.0 release" + ) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_previous_release_skips_missing_patch_numbers(): + assert release_ranges.previous_release((1, 0, 3), [(1, 0, 0), (1, 0, 3)]) == (1, 0, 0) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_render_page_orders_sections_links_prs_and_uses_issue_source(): + notes = [ + {"data": {"fixes": ["A fix."], "issues": ["Not shown: issues come from the issue source."]}, "pr": "7"}, + {"data": {"features": ["A feature.\n\nMore detail."]}, "pr": None}, + ] + issues = [{"data": {"issues": ["An open issue."]}, "pr": "3"}] + page = release_notes.render_page("Title", SECTIONS, notes, issues, PR_URL) + assert page.index("New Features") < page.index("Bug Fixes") < page.index("Known Issues") + assert "- A fix. (`PR #7 `__)" in page + assert "- A feature.\n\n More detail." in page + assert "An open issue. (`PR #3 `__)" in page + assert "Not shown" not in page + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_render_page_empty(): + assert "There are no release notes" in release_notes.render_page("Title", SECTIONS, [], [], PR_URL) + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", "-C", str(repo), *args], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def _note(repo: Path, name: str, section: str, text: str, uid: str) -> Path: + path = repo / NOTES_DIR / f"{name}-{uid:0>16}.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"---\n{section}:\n - |\n {text}\n", encoding="utf-8") + return path + + +def _commit(repo: Path, message: str) -> None: + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", message) + + +@pytest.fixture +def repo(tmp_path): + root = tmp_path / "repo" + root.mkdir() + _git(root, "init", "-q", "-b", "main") + _git(root, "config", "user.email", "test@example.test") + _git(root, "config", "user.name", "Test") + (root / NOTES_DIR).mkdir(parents=True) + (root / "docs" / "source").mkdir(parents=True) + + _note(root, "first-fix", "fixes", "First fix.", "1") + _note(root, "open-issue", "issues", "Open issue.", "2") + _note(root, "wsl-issue", "issues", "Resolved issue.", "3") + _commit(root, "Initial notes (#1)") + _git(root, "tag", "v1.0.0") + _git(root, "tag", "v1.0.0rc1") + + _note(root, "new-feature", "features", "New feature.", "4") + _commit(root, "Add feature (#2)") + _note(root, "patch-fix", "fixes", "Fix needed in the patch.", "5") + _commit(root, "Fix (#3)") + _git(root, "tag", "v1.1.0") + + # The patch release is tagged on a maintenance branch that diverges from main. + _git(root, "checkout", "-q", "-b", "maint", "v1.0.0") + _note(root, "patch-fix", "fixes", "Fix needed in the patch.", "5") + _commit(root, "Backport fix (#3)") + _git(root, "tag", "v1.0.1") + + _git(root, "checkout", "-q", "main") + _git(root, "rm", "-q", str(root / NOTES_DIR / "wsl-issue-0000000000000003.yaml")) + _note(root, "after", "upgrade", "Change after the release.", "6") + _commit(root, "Resolve issue and add change") + return root + + +def _app(root: Path): + config = types.SimpleNamespace( + release_notes_dir=NOTES_DIR, + release_notes_tag_prefix="v", + release_notes_first_version="1.0.0", + release_notes_title="Package {version}", + release_notes_unreleased_title="In development", + release_notes_prolog=".. currentmodule:: pkg", + release_notes_pr_url=PR_URL, + release_notes_output_dir="release", + ) + return types.SimpleNamespace(srcdir=str(root / "docs" / "source"), config=config) + + +def _pages(root: Path) -> dict[str, str]: + out = root / "docs" / "source" / "release" + return {p.name: p.read_text(encoding="utf-8") for p in out.glob("*-notes.rst")} + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_generate_pages_ranges_and_known_issues(repo): + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + + # No page for the rc tag; one per release tag plus the unreleased page. + assert set(pages) == {"1.0.0-notes.rst", "1.0.1-notes.rst", "1.1.0-notes.rst", "unreleased-notes.rst"} + + assert "First fix." in pages["1.0.0-notes.rst"] + + # The prolog comes before the title, so release_date still finds the title underline. + assert pages["1.0.0-notes.rst"].startswith( + release_notes.GENERATED_MARKER + ".. currentmodule:: pkg\n\nPackage 1.0.0\n" + ) + + # The patch page lists only the backported note, even though the tag is + # not an ancestor of main. + assert "Fix needed in the patch." in pages["1.0.1-notes.rst"] + assert "First fix." not in pages["1.0.1-notes.rst"] + + # The minor page lists the same fix again, plus the feature, but not the + # notes of the previous minor release. + assert "Fix needed in the patch." in pages["1.1.0-notes.rst"] + assert "New feature." in pages["1.1.0-notes.rst"] + assert "First fix." not in pages["1.1.0-notes.rst"] + assert "(`PR #2 `__)" in pages["1.1.0-notes.rst"] + + # Known issues are listed on every release that still contains them ... + for name in ("1.0.0-notes.rst", "1.0.1-notes.rst", "1.1.0-notes.rst"): + assert "Open issue." in pages[name] + assert "Resolved issue." in pages[name] + + # ... and the unreleased page shows only what is new, with the resolved issue gone. + unreleased = pages["unreleased-notes.rst"] + assert unreleased.removeprefix(release_notes.GENERATED_MARKER + ".. currentmodule:: pkg\n\n").startswith( + "In development\n" + ) + assert "Change after the release." in unreleased + assert "Open issue." in unreleased + assert "Resolved issue." not in unreleased + assert "Fix needed in the patch." not in unreleased + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_edited_known_issues_do_not_change_earlier_pages(repo): + # The issues entry of a note that still exists is removed (the note keeps another section). + path = _note(repo, "open-issue", "fixes", "Now a fix.", "2") + _commit(repo, "Resolve the open issue") + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + assert path.is_file() + # Pages for tags keep the issue as it was at the tag ... + assert "Open issue." in pages["1.0.0-notes.rst"] + assert "Open issue." in pages["1.1.0-notes.rst"] + # ... but the unreleased page reflects the checkout. + assert "Open issue." not in pages["unreleased-notes.rst"] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_empty_note_at_a_tag_is_tolerated(repo): + empty = repo / NOTES_DIR / "empty-note-00000000000000ff.yaml" + empty.write_text("# nothing yet\n", encoding="utf-8") + _commit(repo, "Add empty note (#9)") + _git(repo, "tag", "v1.2.0") + empty.unlink() + _commit(repo, "Remove empty note") + release_notes._generate_pages(_app(repo)) + assert "1.2.0-notes.rst" in _pages(repo) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_generate_pages_removes_stale_pages(repo): + release_notes._generate_pages(_app(repo)) + release_dir = repo / "docs" / "source" / "release" + stale = release_dir / "9.9.9-notes.rst" + stale.write_text(release_notes.GENERATED_MARKER + "stale", encoding="utf-8") + handwritten = release_dir / "0.9.0-notes.rst" + handwritten.write_text("hand-written", encoding="utf-8") + release_notes._generate_pages(_app(repo)) + assert not stale.exists() + assert handwritten.read_text(encoding="utf-8") == "hand-written" + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_generate_pages_refuses_to_overwrite_handwritten_pages(repo): + release_dir = repo / "docs" / "source" / "release" + release_dir.mkdir(parents=True) + (release_dir / "1.0.0-notes.rst").write_text("hand-written", encoding="utf-8") + with pytest.raises(release_notes.ExtensionError, match="hand-written"): + release_notes._generate_pages(_app(repo)) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_generate_pages_rejects_shallow_clone(repo, tmp_path): + shallow = tmp_path / "shallow" + subprocess.run( # noqa: S603 + ["git", "clone", "-q", "--depth", "1", f"file://{repo}", str(shallow)], # noqa: S607 + check=True, + ) + (shallow / "docs" / "source").mkdir(parents=True, exist_ok=True) + with pytest.raises(release_notes.ExtensionError, match="shallow"): + release_notes._generate_pages(_app(shallow)) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_generate_pages_requires_tags(repo): + for tag in _git(repo, "tag", "--list").split(): + _git(repo, "tag", "-d", tag) + with pytest.raises(release_notes.ExtensionError, match="no release tags"): + release_notes._generate_pages(_app(repo)) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + ("entry", "expected"), + [ + ("Text. (#12)", "Text. (`PR #12 `__)"), + ( + "Text. (#12, #34)\n", + "Text. (`PR #12 `__, `PR #34 `__)", + ), + ( + "Text. ( #12 ,#34 )", + "Text. (`PR #12 `__, `PR #34 `__)", + ), + # As a final paragraph, so it also works after a list or a table. + ("Para.\n\n- item\n\n(#5)", "Para.\n\n- item\n\n(`PR #5 `__)"), + ], +) +def test_explicit_pr_numbers_are_linked(entry, expected): + # The automatic number is ignored when the entry names its own. + assert release_notes._render_entry(entry, "999", PR_URL) == expected + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_explicit_pr_numbers_work_anywhere_in_the_entry(): + link = "`PR #12 `__" + # In the middle of a sentence, and no automatic link is added. + assert ( + release_notes._render_entry("Fixed in (#12) as well as other things.", "5", PR_URL) + == f"Fixed in ({link}) as well as other things." + ) + # More than one marker, in different paragraphs. + assert release_notes._render_entry("One (#12).\n\nTwo (#12).", None, PR_URL) == f"One ({link}).\n\nTwo ({link})." + # Things that merely look similar are not markers. + for text in ("See issue #12.", "Ends with (#12x)", "Ends with (see #12)", "Ends with (12)"): + assert release_notes._render_entry(text, None, PR_URL) == text + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize( + "text", + [ + "Call ``f(#5)`` to do it.", + "Call `(#99)` to do it.", + "A literal that\nspans ``f(\n(#5)\n)`` lines.", + "Both ``(#5)`` and ``(#6, #7)`` are literal text.", + ], +) +def test_markers_inside_backquoted_text_are_left_alone(text): + assert not release_notes._has_explicit_prs(text) + # With no real marker, the automatic link is added to the first paragraph, and the text is intact. + rendered = release_notes._render_entry(text, "5", PR_URL) + assert rendered == f"{text} (`PR #5 `__)" + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_a_marker_outside_backquoted_text_still_works_next_to_one_inside(): + link = "`PR #8 `__" + # The real marker replaces the automatic link; the literal one is untouched. + assert release_notes._render_entry("Use ``f(#5)`` (#8).", "1", PR_URL) == f"Use ``f(#5)`` ({link})." + # Notes whose only markers are literal still use the git lookup. + assert release_notes._needs_pr_lookup({"fixes": ["Use ``f(#5)``."]}) + assert not release_notes._needs_pr_lookup({"fixes": ["Use ``f(#5)`` (#8)."]}) + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_explicit_pr_numbers_skip_the_git_lookup_and_apply_per_entry(repo): + # The note file is added by a "move" commit (#9) but its entries name the real PRs. + _git(repo, "checkout", "-q", "main") + path = repo / NOTES_DIR / "moved-0000000000000007.yaml" + path.write_text( + "---\nfixes:\n - |\n Moved fix. (#77, #78)\n - |\n Entry without a number.\n" + "issues:\n - |\n Moved issue. (#99)\n", + encoding="utf-8", + ) + only_explicit = repo / NOTES_DIR / "explicit-0000000000000008.yaml" + only_explicit.write_text("---\nfeatures:\n - |\n All explicit. (#55)\n", encoding="utf-8") + _commit(repo, "Move notes into place (#9)") + _git(repo, "tag", "v1.2.0") + release_notes._generate_pages(_app(repo)) + page = _pages(repo)["1.2.0-notes.rst"] + + assert "Moved fix. (`PR #77 `__, `PR #78 `__)" in page + assert "Moved issue. (`PR #99 `__)" in page + assert "All explicit. (`PR #55 `__)" in page + # An entry without a number in the same note still uses the commit subject. + assert "Entry without a number. (`PR #9 `__)" in page + assert page.count("pull/9>") == 1 + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_minor_release_starting_at_a_patch_number_and_relisted_patches(repo): + # 2.0.0 and 2.0.1, then a line that starts at 2.1.1 (2.1.0 was only a pre-release). + _git(repo, "checkout", "-q", "main") + _note(repo, "n200", "fixes", "Fix in 2.0.0.", "c") + _commit(repo, "Fix (#20)") + _git(repo, "tag", "v2.0.0") + _git(repo, "tag", "v2.1.0rc1") + _note(repo, "n201", "fixes", "Fix in 2.0.1.", "d") + _commit(repo, "Fix (#21)") + _git(repo, "tag", "v2.0.1") + _note(repo, "n211", "fixes", "Fix in 2.1.1.", "e") + _commit(repo, "Fix (#22)") + _git(repo, "tag", "v2.1.1") + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + + assert "2.1.0-notes.rst" not in pages + assert "Fix in 2.0.1." in pages["2.0.1-notes.rst"] + assert "Fix in 2.0.0." not in pages["2.0.1-notes.rst"] + # The minor release is relative to 2.0.0, so it re-lists the 2.0.1 patch. + assert "Fix in 2.1.1." in pages["2.1.1-notes.rst"] + assert "Fix in 2.0.1." in pages["2.1.1-notes.rst"] + assert "Fix in 2.0.0." not in pages["2.1.1-notes.rst"] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_pages_for_releases_on_other_branches_and_the_unreleased_page(repo): + # The newest tag, v1.1.1, is only on a maintenance branch; main continues after v1.1.0. + _git(repo, "checkout", "-q", "-b", "maint-1.1", "v1.1.0") + _note(repo, "maint-fix", "fixes", "Fix only on the maintenance branch.", "b") + _commit(repo, "Backport (#30)") + _git(repo, "tag", "v1.1.1") + _git(repo, "checkout", "-q", "main") + + # Building on main does not fail, and shows the patch release's page ... + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + assert "Fix only on the maintenance branch." in pages["1.1.1-notes.rst"] + # ... while the unreleased page is relative to the newest release in main's history. + unreleased = pages["unreleased-notes.rst"] + assert "Change after the release." in unreleased + assert "Fix only on the maintenance branch." not in unreleased + + # Building on the maintenance branch, with a newer minor release only on main. + # (Remove the generated pages first so that committing does not sweep them into git.) + shutil.rmtree(repo / "docs" / "source" / "release") + _git(repo, "checkout", "-q", "main") + _note(repo, "next", "features", "Next minor feature.", "c") + _commit(repo, "Next feature (#31)") + _git(repo, "tag", "v1.2.0") + _git(repo, "checkout", "-q", "maint-1.1") + _note(repo, "maint-only", "fixes", "Not yet released on the maintenance branch.", "d") + _commit(repo, "Another fix (#32)") + (repo / "docs" / "source").mkdir(parents=True, exist_ok=True) + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + assert "Next minor feature." in pages["1.2.0-notes.rst"] + unreleased = pages["unreleased-notes.rst"] + assert "Not yet released on the maintenance branch." in unreleased + assert "Next minor feature." not in unreleased + assert "Fix only on the maintenance branch." not in unreleased # it is in v1.1.1, the baseline + + +def _rewrite(repo, name, uid, text): + """Edit a note in the checkout, without committing (as when the docs are built from main).""" + path = next((repo / NOTES_DIR).glob(f"{name}-*{uid}.yaml")) + path.write_text(path.read_text(encoding="utf-8").replace(OLD_TEXTS[name], text), encoding="utf-8") + + +OLD_TEXTS = { + "first-fix": "First fix.", + "patch-fix": "Fix needed in the patch.", + "open-issue": "Open issue.", +} + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_edits_to_released_notes_change_the_pages_that_list_them(repo): + # Which notes a page lists comes from the tags; what they say comes from the checkout (known issues excepted). + _rewrite(repo, "first-fix", "1", "First fix, reworded.") + _rewrite(repo, "patch-fix", "5", "Patch fix, reworded.") + _rewrite(repo, "open-issue", "2", "Open issue, reworded.") + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + + assert "First fix, reworded." in pages["1.0.0-notes.rst"] + assert "First fix." not in pages["1.0.0-notes.rst"] + # A note that was backported is on the patch page and on the next minor page, with the new text. + for name in ("1.0.1-notes.rst", "1.1.0-notes.rst"): + assert "Patch fix, reworded." in pages[name] + assert "Fix needed in the patch." not in pages[name] + # A known issue keeps the text it had at each tag; only the unreleased page shows the edit. + for name in ("1.0.0-notes.rst", "1.0.1-notes.rst", "1.1.0-notes.rst"): + assert "Open issue." in pages[name] + assert "reworded" not in pages[name].split("Known Issues")[1] + assert "Open issue, reworded." in pages["unreleased-notes.rst"] + # Editing a note does not move it to another release. + assert "First fix" not in pages["1.1.0-notes.rst"] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_a_note_missing_from_the_checkout_keeps_its_text_at_the_tag(repo): + # "Resolved issue." was deleted on main (see the fixture) but was in the tree at 1.0.0 and 1.1.0. + # The note is not in the checkout, so the pages use its text at their own tags. + # A note only on the maintenance branch is likewise missing from main's checkout. + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + assert "Resolved issue." in pages["1.0.0-notes.rst"] + assert "Resolved issue." in pages["1.1.0-notes.rst"] + assert "Resolved issue." not in pages["unreleased-notes.rst"] + + # (Remove the generated pages first so that committing does not sweep them into git.) + shutil.rmtree(repo / "docs" / "source" / "release") + _git(repo, "checkout", "-q", "-b", "maint-only", "v1.0.1") + _note(repo, "branch-only", "fixes", "Only on a maintenance branch.", "f") + _commit(repo, "Branch-only fix (#40)") + _git(repo, "tag", "v1.0.2") + _git(repo, "checkout", "-q", "main") + (repo / "docs" / "source").mkdir(parents=True, exist_ok=True) + release_notes._generate_pages(_app(repo)) + assert "Only on a maintenance branch." in _pages(repo)["1.0.2-notes.rst"] + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_sections_use_only_the_default_keys_with_known_issues_last(): + keys = [key for key, _title in release_ranges.SECTIONS] + assert set(keys) == DEFAULT_SECTION_KEYS + assert len(keys) == len(DEFAULT_SECTION_KEYS) + assert keys[-1] == "issues" + assert release_ranges.PRELUDE not in keys + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_the_upgrade_section_is_titled_breaking_changes(): + # cuda_core/docs/source/support.rst points readers to "Breaking Changes" in the release notes. + assert dict(release_ranges.SECTIONS)["upgrade"] == "Breaking Changes" + + +def _ignored(package_dir, name): + result = subprocess.run( # noqa: S603 + ["git", "check-ignore", "-q", "--no-index", f"{package_dir}/docs/source/release/{name}-notes.rst"], # noqa: S607 + cwd=REPO_ROOT, + check=False, + ) + return result.returncode == 0 + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +@pytest.mark.parametrize("component", sorted(release_ranges.PACKAGES)) +def test_gitignore_covers_generated_pages_and_not_hand_written_ones(component): + package = release_ranges.PACKAGES[component] + package_dir = package.notes_dir.removesuffix("/releasenotes") + major, minor, _patch = package.first_version + fmt = release_ranges.format_version + + generated = [ + package.first_version, + (major, minor, 11), + (major, minor + 1, 0), + (major, minor + 10, 0), + (major, minor + 100, 0), + (major + 1, 0, 0), + (major + 90, 0, 0), + (major + 900, 0, 0), + ] + for version in generated: + assert _ignored(package_dir, fmt(version)), f"{package_dir}: {fmt(version)} should be ignored" + assert _ignored(package_dir, "unreleased") + + # Releases before the first version with note files keep their tracked, hand-written pages. + hand_written = [(major, minor - 1, 0), (major, minor - 1, 10), (major - 1, 9, 9), (major - 1, 100, 0)] + for version in hand_written: + if min(version) < 0: + continue + assert not _ignored(package_dir, fmt(version)), f"{package_dir}: {fmt(version)} must not be ignored" + assert not _ignored(package_dir, f"{fmt((major, minor - 1, 0))}rc1") + + +@pytest.mark.agent_authored(model="claude-sonnet-5-5") +def test_a_renamed_note_counts_as_a_new_note_in_the_release_that_contains_the_rename(repo): + # "New feature." was added for 1.1.0. Renaming its file is a delete and an add, so the + # note is not lost (git would otherwise report a rename, not an add), and it is not listed + # twice. + _git(repo, "checkout", "-q", "main") + old = next((repo / NOTES_DIR).glob("new-feature-*.yaml")) + new = old.with_name("renamed-feature-0000000000000004.yaml") + _git(repo, "mv", str(old), str(new)) + _git(repo, "commit", "-q", "-m", "Rename a note (#50)") + _git(repo, "tag", "v1.2.0") + release_notes._generate_pages(_app(repo)) + pages = _pages(repo) + + assert pages["1.1.0-notes.rst"].count("New feature.") == 1 # built from its own tag: the old name + assert pages["1.2.0-notes.rst"].count("New feature.") == 1 # a minor release: the new name diff --git a/cuda_bindings/AGENTS.md b/cuda_bindings/AGENTS.md index e9e900c2185..8160382e015 100644 --- a/cuda_bindings/AGENTS.md +++ b/cuda_bindings/AGENTS.md @@ -50,6 +50,21 @@ and `nvrtc`. These test files should not be added to, only updated when necessary to fix test failures. The canonical set of tests are those outside of the `legacy_tests` subdirectory. +## Release notes + +- Notes for 13.5.0 and later are per-change files in `releasenotes/`; do not + add hand-written pages to `docs/source/release/` for new releases. +- Changes to `cuda/`, `pyproject.toml`, `setup.py`, `build_hooks.py` or + `MANIFEST.in` need a note, unless the PR has the `skip-release-note` label. + Create one with + `python toolshed/add_note.py cuda-bindings `. +- Entries use `cuda.bindings` as the current module, so a Sphinx role can say + `:func:`nvml.device_get_samples``, without the `cuda.bindings.` prefix. +- Do not add PR links to notes (they are added automatically) and do not + rename merged notes. +- `issues` (known issues) entries are repeated on every later release until + deleted. See the "Release notes" section of the top-level `CONTRIBUTING.md`. + ## Build and environment notes - `CUDA_HOME` or `CUDA_PATH` must point to a valid CUDA Toolkit for source diff --git a/cuda_bindings/docs/source/conf.py b/cuda_bindings/docs/source/conf.py index 8a7e15d1be0..89ac6f2a6de 100644 --- a/cuda_bindings/docs/source/conf.py +++ b/cuda_bindings/docs/source/conf.py @@ -15,6 +15,7 @@ sys.path.insert(0, str((Path(__file__).parents[3] / "cuda_python" / "docs" / "exts").absolute())) +from release_ranges import PACKAGES, format_version # -- Project information ----------------------------------------------------- @@ -61,9 +62,19 @@ def _html_baseurl(): "sphinx_copybutton", "release_toc", "release_date", + "release_notes", "enum_documenter", ] +# Release notes from 13.5.0 on are built from the notes in +# cuda_bindings/releasenotes; earlier ones are hand-written pages in release/. +release_notes_dir = PACKAGES["cuda-bindings"].notes_dir +release_notes_tag_prefix = PACKAGES["cuda-bindings"].tag_prefix +release_notes_first_version = format_version(PACKAGES["cuda-bindings"].first_version) +release_notes_title = "``cuda-bindings`` {version} Release notes" +release_notes_unreleased_title = "``cuda-bindings`` unreleased changes" +release_notes_prolog = ".. currentmodule:: cuda.bindings" + nb_execution_mode = "off" numfig = True diff --git a/cuda_bindings/docs/source/release.rst b/cuda_bindings/docs/source/release.rst index f844bfc251d..b8726b4d661 100644 --- a/cuda_bindings/docs/source/release.rst +++ b/cuda_bindings/docs/source/release.rst @@ -8,4 +8,4 @@ Release Notes :maxdepth: 3 :glob: - release/*[0-9]-notes + release/*-notes diff --git a/cuda_bindings/docs/source/release/.gitignore b/cuda_bindings/docs/source/release/.gitignore new file mode 100644 index 00000000000..85281f90081 --- /dev/null +++ b/cuda_bindings/docs/source/release/.gitignore @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Pages generated by cuda_python/docs/exts/release_notes.py, which covers +# releases from the first reno version on (see release_ranges.PACKAGES). Releases +# before it keep their hand-written pages here. +/unreleased-notes.rst +/13.[5-9]*-notes.rst +/13.[1-9][0-9]*-notes.rst +/1[4-9]*-notes.rst +/[2-9][0-9]*-notes.rst +/[1-9][0-9][0-9]*-notes.rst diff --git a/cuda_bindings/docs/source/release/13.5.0-notes.rst b/cuda_bindings/docs/source/release/13.5.0-notes.rst deleted file mode 100644 index 356a26a0c6f..00000000000 --- a/cuda_bindings/docs/source/release/13.5.0-notes.rst +++ /dev/null @@ -1,334 +0,0 @@ -.. SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -.. SPDX-License-Identifier: Apache-2.0 - -.. module:: cuda.bindings - -``cuda-bindings`` 13.5.0 Release notes -====================================== - -Changes -------- - -- Many ``cuda.bindings.cudla`` and ``cuda.bindings.nvml`` enum members had a - redundant prefix stripped from their name, - e.g. ``nvml.BrandType.BRAND_GEFORCE`` is now ``nvml.BrandType.GEFORCE``. - The old, longer names remain available as backward-compatible aliases and - will continue to work. The full list of renames, excluding the 447 - similarly-renamed ``nvml.GpmMetricId`` members (which had their - ``GPM_METRIC_`` prefix stripped the same way), is below. - -.. csv-table:: Enum member renames (old names remain available as backward-compatible aliases) - :header: "Module", "Enum class", "New name", "Deprecated alias" - :widths: 10, 25, 25, 25 - - "cudla", "FenceType", "FENCE", "NVSCISYNC_FENCE" - "cudla", "FenceType", "FENCE_SOF", "NVSCISYNC_FENCE_SOF" - "cudla", "ModuleLoadFlags", "DEFAULT", "MODULE_DEFAULT" - "cudla", "ModuleLoadFlags", "ENABLE_FAULT_DIAGNOSTICS", "MODULE_ENABLE_FAULT_DIAGNOSTICS" - "cudla", "ScratchMemoryConfig", "CONFIG_MAX", "MAX" - "cudla", "ScratchMemoryConfig", "DEFAULT", "SCRATCH_MEMORY_DEFAULT" - "cudla", "ScratchMemoryConfig", "SHARED_STATIC", "SCRATCH_MEMORY_SHARED_STATIC" - "cudla", "SubmissionFlags", "DIAGNOSTICS_TASK", "SUBMIT_DIAGNOSTICS_TASK" - "cudla", "SubmissionFlags", "NOOP", "SUBMIT_NOOP" - "cudla", "SubmissionFlags", "SKIP_LOCK_ACQUIRE", "SUBMIT_SKIP_LOCK_ACQUIRE" - "nvml", "BrandType", "COUNT", "BRAND_COUNT" - "nvml", "BrandType", "GEFORCE", "BRAND_GEFORCE" - "nvml", "BrandType", "GEFORCE_RTX", "BRAND_GEFORCE_RTX" - "nvml", "BrandType", "GRID", "BRAND_GRID" - "nvml", "BrandType", "NVIDIA", "BRAND_NVIDIA" - "nvml", "BrandType", "NVIDIA_CLOUD_GAMING", "BRAND_NVIDIA_CLOUD_GAMING" - "nvml", "BrandType", "NVIDIA_DLA", "BRAND_NVIDIA_DLA" - "nvml", "BrandType", "NVIDIA_NPU", "BRAND_NVIDIA_NPU" - "nvml", "BrandType", "NVIDIA_RTX", "BRAND_NVIDIA_RTX" - "nvml", "BrandType", "NVIDIA_VAPPS", "BRAND_NVIDIA_VAPPS" - "nvml", "BrandType", "NVIDIA_VCS", "BRAND_NVIDIA_VCS" - "nvml", "BrandType", "NVIDIA_VGAMEDEV", "BRAND_NVIDIA_VGAMEDEV" - "nvml", "BrandType", "NVIDIA_VGAMING", "BRAND_NVIDIA_VGAMING" - "nvml", "BrandType", "NVIDIA_VPC", "BRAND_NVIDIA_VPC" - "nvml", "BrandType", "NVIDIA_VWS", "BRAND_NVIDIA_VWS" - "nvml", "BrandType", "NVS", "BRAND_NVS" - "nvml", "BrandType", "QUADRO", "BRAND_QUADRO" - "nvml", "BrandType", "QUADRO_RTX", "BRAND_QUADRO_RTX" - "nvml", "BrandType", "TESLA", "BRAND_TESLA" - "nvml", "BrandType", "TITAN", "BRAND_TITAN" - "nvml", "BrandType", "TITAN_RTX", "BRAND_TITAN_RTX" - "nvml", "BrandType", "UNKNOWN", "BRAND_UNKNOWN" - "nvml", "BridgeChipType", "BRO4", "BRIDGE_CHIP_BRO4" - "nvml", "BridgeChipType", "PLX", "BRIDGE_CHIP_PLX" - "nvml", "ClockType", "COUNT", "CLOCK_COUNT" - "nvml", "ClockType", "GRAPHICS", "CLOCK_GRAPHICS" - "nvml", "ClockType", "MEM", "CLOCK_MEM" - "nvml", "ClockType", "SM", "CLOCK_SM" - "nvml", "ClockType", "VIDEO", "CLOCK_VIDEO" - "nvml", "ComputeMode", "COUNT", "COMPUTEMODE_COUNT" - "nvml", "ComputeMode", "DEFAULT", "COMPUTEMODE_DEFAULT" - "nvml", "ComputeMode", "EXCLUSIVE_PROCESS", "COMPUTEMODE_EXCLUSIVE_PROCESS" - "nvml", "ComputeMode", "EXCLUSIVE_THREAD", "COMPUTEMODE_EXCLUSIVE_THREAD" - "nvml", "ComputeMode", "PROHIBITED", "COMPUTEMODE_PROHIBITED" - "nvml", "CoolerControl", "COUNT", "THERMAL_COOLER_SIGNAL_COUNT" - "nvml", "CoolerControl", "NONE", "THERMAL_COOLER_SIGNAL_NONE" - "nvml", "CoolerControl", "TOGGLE", "THERMAL_COOLER_SIGNAL_TOGGLE" - "nvml", "CoolerControl", "VARIABLE", "THERMAL_COOLER_SIGNAL_VARIABLE" - "nvml", "CoolerTarget", "GPU", "THERMAL_GPU" - "nvml", "CoolerTarget", "GPU_RELATED", "THERMAL_GPU_RELATED" - "nvml", "CoolerTarget", "MEMORY", "THERMAL_MEMORY" - "nvml", "CoolerTarget", "NONE", "THERMAL_NONE" - "nvml", "CoolerTarget", "POWER_SUPPLY", "THERMAL_POWER_SUPPLY" - "nvml", "DetachGpuState", "KEEP", "DETACH_GPU_KEEP" - "nvml", "DetachGpuState", "REMOVE", "DETACH_GPU_REMOVE" - "nvml", "DeviceAddressingModeType", "ATS", "DEVICE_ADDRESSING_MODE_ATS" - "nvml", "DeviceAddressingModeType", "HMM", "DEVICE_ADDRESSING_MODE_HMM" - "nvml", "DeviceAddressingModeType", "NONE", "DEVICE_ADDRESSING_MODE_NONE" - "nvml", "DeviceGpuRecoveryAction", "BUS_RESET", "GPU_RECOVERY_ACTION_BUS_RESET" - "nvml", "DeviceGpuRecoveryAction", "DRAIN_AND_RESET", "GPU_RECOVERY_ACTION_DRAIN_AND_RESET" - "nvml", "DeviceGpuRecoveryAction", "DRAIN_P2P", "GPU_RECOVERY_ACTION_DRAIN_P2P" - "nvml", "DeviceGpuRecoveryAction", "GPU_RESET", "GPU_RECOVERY_ACTION_GPU_RESET" - "nvml", "DeviceGpuRecoveryAction", "NODE_REBOOT", "GPU_RECOVERY_ACTION_NODE_REBOOT" - "nvml", "DeviceGpuRecoveryAction", "NONE", "GPU_RECOVERY_ACTION_NONE" - "nvml", "DeviceGpuRecoveryAction", "RECOVER_IMEX_DOMAIN", "GPU_RECOVERY_ACTION_RECOVER_IMEX_DOMAIN" - "nvml", "DeviceGpuRecoveryAction", "SYSTEM_REBOOT", "GPU_RECOVERY_ACTION_SYSTEM_REBOOT" - "nvml", "DeviceVgpuCapability", "COMPUTE_MEDIA_ENGINE_GPU", "DEVICE_VGPU_CAP_COMPUTE_MEDIA_ENGINE_GPU" - "nvml", "DeviceVgpuCapability", "COUNT", "DEVICE_VGPU_CAP_COUNT" - "nvml", "DeviceVgpuCapability", "DEVICE_STREAMING", "DEVICE_VGPU_CAP_DEVICE_STREAMING" - "nvml", "DeviceVgpuCapability", "FRACTIONAL_MULTI_VGPU", "DEVICE_VGPU_CAP_FRACTIONAL_MULTI_VGPU" - "nvml", "DeviceVgpuCapability", "HETEROGENEOUS_TIMESLICE_PROFILES", "DEVICE_VGPU_CAP_HETEROGENEOUS_TIMESLICE_PROFILES" - "nvml", "DeviceVgpuCapability", "HETEROGENEOUS_TIMESLICE_SIZES", "DEVICE_VGPU_CAP_HETEROGENEOUS_TIMESLICE_SIZES" - "nvml", "DeviceVgpuCapability", "HOMOGENEOUS_PLACEMENTS", "DEVICE_VGPU_CAP_HOMOGENEOUS_PLACEMENTS" - "nvml", "DeviceVgpuCapability", "MIG_TIMESLICING_ENABLED", "DEVICE_VGPU_CAP_MIG_TIMESLICING_ENABLED" - "nvml", "DeviceVgpuCapability", "MIG_TIMESLICING_SUPPORTED", "DEVICE_VGPU_CAP_MIG_TIMESLICING_SUPPORTED" - "nvml", "DeviceVgpuCapability", "MINI_QUARTER_GPU", "DEVICE_VGPU_CAP_MINI_QUARTER_GPU" - "nvml", "DeviceVgpuCapability", "READ_DEVICE_BUFFER_BW", "DEVICE_VGPU_CAP_READ_DEVICE_BUFFER_BW" - "nvml", "DeviceVgpuCapability", "WARM_UPDATE", "DEVICE_VGPU_CAP_WARM_UPDATE" - "nvml", "DeviceVgpuCapability", "WRITE_DEVICE_BUFFER_BW", "DEVICE_VGPU_CAP_WRITE_DEVICE_BUFFER_BW" - "nvml", "DriverModel", "MCDM", "DRIVER_MCDM" - "nvml", "DriverModel", "WDDM", "DRIVER_WDDM" - "nvml", "DriverModel", "WDM", "DRIVER_WDM" - "nvml", "EccCounterType", "ECC_COUNTER_TYPE_COUNT", "COUNT" - "nvml", "EnableState", "DISABLED", "FEATURE_DISABLED" - "nvml", "EnableState", "ENABLED", "FEATURE_ENABLED" - "nvml", "EncoderType", "AV1", "ENCODER_QUERY_AV1" - "nvml", "EncoderType", "H264", "ENCODER_QUERY_H264" - "nvml", "EncoderType", "HEVC", "ENCODER_QUERY_HEVC" - "nvml", "EncoderType", "UNKNOWN", "ENCODER_QUERY_UNKNOWN" - "nvml", "EventDataType", "NVML_EVENT", "EVENT" - "nvml", "FanState", "FAILED", "FAN_FAILED" - "nvml", "FanState", "NORMAL", "FAN_NORMAL" - "nvml", "GpuOperationMode", "ALL_ON", "GOM_ALL_ON" - "nvml", "GpuOperationMode", "COMPUTE", "GOM_COMPUTE" - "nvml", "GpuOperationMode", "LOW_DP", "GOM_LOW_DP" - "nvml", "GpuP2PCapsIndex", "ATOMICS", "P2P_CAPS_INDEX_ATOMICS" - "nvml", "GpuP2PCapsIndex", "NVLINK", "P2P_CAPS_INDEX_NVLINK" - "nvml", "GpuP2PCapsIndex", "PCI", "P2P_CAPS_INDEX_PCI" - "nvml", "GpuP2PCapsIndex", "PROP", "P2P_CAPS_INDEX_PROP" - "nvml", "GpuP2PCapsIndex", "READ", "P2P_CAPS_INDEX_READ" - "nvml", "GpuP2PCapsIndex", "UNKNOWN", "P2P_CAPS_INDEX_UNKNOWN" - "nvml", "GpuP2PCapsIndex", "WRITE", "P2P_CAPS_INDEX_WRITE" - "nvml", "GpuP2PStatus", "CHIPSET_NOT_SUPPORED", "P2P_STATUS_CHIPSET_NOT_SUPPORED" - "nvml", "GpuP2PStatus", "CHIPSET_NOT_SUPPORTED", "P2P_STATUS_CHIPSET_NOT_SUPPORTED" - "nvml", "GpuP2PStatus", "DISABLED_BY_REGKEY", "P2P_STATUS_DISABLED_BY_REGKEY" - "nvml", "GpuP2PStatus", "GPU_NOT_SUPPORTED", "P2P_STATUS_GPU_NOT_SUPPORTED" - "nvml", "GpuP2PStatus", "IOH_TOPOLOGY_NOT_SUPPORTED", "P2P_STATUS_IOH_TOPOLOGY_NOT_SUPPORTED" - "nvml", "GpuP2PStatus", "NOT_SUPPORTED", "P2P_STATUS_NOT_SUPPORTED" - "nvml", "GpuP2PStatus", "OK", "P2P_STATUS_OK" - "nvml", "GpuP2PStatus", "UNKNOWN", "P2P_STATUS_UNKNOWN" - "nvml", "GpuTopologyLevel", "HOSTBRIDGE", "TOPOLOGY_HOSTBRIDGE" - "nvml", "GpuTopologyLevel", "INTERNAL", "TOPOLOGY_INTERNAL" - "nvml", "GpuTopologyLevel", "MULTIPLE", "TOPOLOGY_MULTIPLE" - "nvml", "GpuTopologyLevel", "NODE", "TOPOLOGY_NODE" - "nvml", "GpuTopologyLevel", "SINGLE", "TOPOLOGY_SINGLE" - "nvml", "GpuTopologyLevel", "SYSTEM", "TOPOLOGY_SYSTEM" - "nvml", "GpuUtilizationDomainId", "BUS", "GPU_UTILIZATION_DOMAIN_BUS" - "nvml", "GpuUtilizationDomainId", "FB", "GPU_UTILIZATION_DOMAIN_FB" - "nvml", "GpuUtilizationDomainId", "GPU", "GPU_UTILIZATION_DOMAIN_GPU" - "nvml", "GpuUtilizationDomainId", "VID", "GPU_UTILIZATION_DOMAIN_VID" - "nvml", "InforomObject", "COUNT", "INFOROM_COUNT" - "nvml", "InforomObject", "DEN", "INFOROM_DEN" - "nvml", "InforomObject", "ECC", "INFOROM_ECC" - "nvml", "InforomObject", "OEM", "INFOROM_OEM" - "nvml", "InforomObject", "POWER", "INFOROM_POWER" - "nvml", "IntNvLinkDeviceType", "GPU", "NVLINK_DEVICE_TYPE_GPU" - "nvml", "IntNvLinkDeviceType", "IBMNPU", "NVLINK_DEVICE_TYPE_IBMNPU" - "nvml", "IntNvLinkDeviceType", "SWITCH", "NVLINK_DEVICE_TYPE_SWITCH" - "nvml", "IntNvLinkDeviceType", "UNKNOWN", "NVLINK_DEVICE_TYPE_UNKNOWN" - "nvml", "NvLinkCapability", "COUNT", "NVLINK_CAP_COUNT" - "nvml", "NvLinkCapability", "P2P_ATOMICS", "NVLINK_CAP_P2P_ATOMICS" - "nvml", "NvLinkCapability", "P2P_SUPPORTED", "NVLINK_CAP_P2P_SUPPORTED" - "nvml", "NvLinkCapability", "SLI_BRIDGE", "NVLINK_CAP_SLI_BRIDGE" - "nvml", "NvLinkCapability", "SYSMEM_ACCESS", "NVLINK_CAP_SYSMEM_ACCESS" - "nvml", "NvLinkCapability", "SYSMEM_ATOMICS", "NVLINK_CAP_SYSMEM_ATOMICS" - "nvml", "NvLinkCapability", "VALID", "NVLINK_CAP_VALID" - "nvml", "NvLinkErrorCounter", "COUNT", "NVLINK_ERROR_COUNT" - "nvml", "NvLinkErrorCounter", "DL_CRC_DATA", "NVLINK_ERROR_DL_CRC_DATA" - "nvml", "NvLinkErrorCounter", "DL_CRC_FLIT", "NVLINK_ERROR_DL_CRC_FLIT" - "nvml", "NvLinkErrorCounter", "DL_ECC_DATA", "NVLINK_ERROR_DL_ECC_DATA" - "nvml", "NvLinkErrorCounter", "DL_RECOVERY", "NVLINK_ERROR_DL_RECOVERY" - "nvml", "NvLinkErrorCounter", "DL_REPLAY", "NVLINK_ERROR_DL_REPLAY" - "nvml", "NvLinkUtilizationCountPktTypes", "ALL", "NVLINK_COUNTER_PKTFILTER_ALL" - "nvml", "NvLinkUtilizationCountPktTypes", "FLUSH", "NVLINK_COUNTER_PKTFILTER_FLUSH" - "nvml", "NvLinkUtilizationCountPktTypes", "NOP", "NVLINK_COUNTER_PKTFILTER_NOP" - "nvml", "NvLinkUtilizationCountPktTypes", "NRATOM", "NVLINK_COUNTER_PKTFILTER_NRATOM" - "nvml", "NvLinkUtilizationCountPktTypes", "RATOM", "NVLINK_COUNTER_PKTFILTER_RATOM" - "nvml", "NvLinkUtilizationCountPktTypes", "READ", "NVLINK_COUNTER_PKTFILTER_READ" - "nvml", "NvLinkUtilizationCountPktTypes", "RESPDATA", "NVLINK_COUNTER_PKTFILTER_RESPDATA" - "nvml", "NvLinkUtilizationCountPktTypes", "RESPNODATA", "NVLINK_COUNTER_PKTFILTER_RESPNODATA" - "nvml", "NvLinkUtilizationCountPktTypes", "WRITE", "NVLINK_COUNTER_PKTFILTER_WRITE" - "nvml", "NvLinkUtilizationCountUnits", "BYTES", "NVLINK_COUNTER_UNIT_BYTES" - "nvml", "NvLinkUtilizationCountUnits", "COUNT", "NVLINK_COUNTER_UNIT_COUNT" - "nvml", "NvLinkUtilizationCountUnits", "CYCLES", "NVLINK_COUNTER_UNIT_CYCLES" - "nvml", "NvLinkUtilizationCountUnits", "PACKETS", "NVLINK_COUNTER_UNIT_PACKETS" - "nvml", "NvLinkUtilizationCountUnits", "RESERVED", "NVLINK_COUNTER_UNIT_RESERVED" - "nvml", "NvlinkVersion", "NVLINK_VERSION_1_0", "VERSION_1_0" - "nvml", "NvlinkVersion", "NVLINK_VERSION_2_0", "VERSION_2_0" - "nvml", "NvlinkVersion", "NVLINK_VERSION_2_2", "VERSION_2_2" - "nvml", "NvlinkVersion", "NVLINK_VERSION_3_0", "VERSION_3_0" - "nvml", "NvlinkVersion", "NVLINK_VERSION_3_1", "VERSION_3_1" - "nvml", "NvlinkVersion", "NVLINK_VERSION_4_0", "VERSION_4_0" - "nvml", "NvlinkVersion", "NVLINK_VERSION_5_0", "VERSION_5_0" - "nvml", "NvlinkVersion", "NVLINK_VERSION_6_0", "VERSION_6_0" - "nvml", "NvlinkVersion", "NVLINK_VERSION_INVALID", "VERSION_INVALID" - "nvml", "PcieLinkState", "KEEP", "PCIE_LINK_KEEP" - "nvml", "PcieLinkState", "SHUT_DOWN", "PCIE_LINK_SHUT_DOWN" - "nvml", "PcieUtilCounter", "COUNT", "PCIE_UTIL_COUNT" - "nvml", "PcieUtilCounter", "RX_BYTES", "PCIE_UTIL_RX_BYTES" - "nvml", "PcieUtilCounter", "TX_BYTES", "PCIE_UTIL_TX_BYTES" - "nvml", "PerfPolicyType", "BOARD_LIMIT", "PERF_POLICY_BOARD_LIMIT" - "nvml", "PerfPolicyType", "COUNT", "PERF_POLICY_COUNT" - "nvml", "PerfPolicyType", "LOW_UTILIZATION", "PERF_POLICY_LOW_UTILIZATION" - "nvml", "PerfPolicyType", "POWER", "PERF_POLICY_POWER" - "nvml", "PerfPolicyType", "RELIABILITY", "PERF_POLICY_RELIABILITY" - "nvml", "PerfPolicyType", "SYNC_BOOST", "PERF_POLICY_SYNC_BOOST" - "nvml", "PerfPolicyType", "THERMAL", "PERF_POLICY_THERMAL" - "nvml", "PerfPolicyType", "TOTAL_APP_CLOCKS", "PERF_POLICY_TOTAL_APP_CLOCKS" - "nvml", "PerfPolicyType", "TOTAL_BASE_CLOCKS", "PERF_POLICY_TOTAL_BASE_CLOCKS" - "nvml", "PowerProfileType", "BALANCED", "POWER_PROFILE_BALANCED" - "nvml", "PowerProfileType", "COMPUTE", "POWER_PROFILE_COMPUTE" - "nvml", "PowerProfileType", "DCPCIE", "POWER_PROFILE_DCPCIE" - "nvml", "PowerProfileType", "HIGH_THROUGHPUT_INFERENCE", "POWER_PROFILE_HIGH_THROUGHPUT_INFERENCE" - "nvml", "PowerProfileType", "HMMA_DENSE", "POWER_PROFILE_HMMA_DENSE" - "nvml", "PowerProfileType", "HMMA_SPARSE", "POWER_PROFILE_HMMA_SPARSE" - "nvml", "PowerProfileType", "HPC", "POWER_PROFILE_HPC" - "nvml", "PowerProfileType", "INFERENCE", "POWER_PROFILE_INFERENCE" - "nvml", "PowerProfileType", "LLM_INFERENCE", "POWER_PROFILE_LLM_INFERENCE" - "nvml", "PowerProfileType", "LLM_TRAINING", "POWER_PROFILE_LLM_TRAINING" - "nvml", "PowerProfileType", "LOW_LATENCY_INFERENCE", "POWER_PROFILE_LOW_LATENCY_INFERENCE" - "nvml", "PowerProfileType", "LOW_PRIORITY_BACKGROUND", "POWER_PROFILE_LOW_PRIORITY_BACKGROUND" - "nvml", "PowerProfileType", "MAX", "POWER_PROFILE_MAX" - "nvml", "PowerProfileType", "MAX_P", "POWER_PROFILE_MAX_P" - "nvml", "PowerProfileType", "MAX_Q", "POWER_PROFILE_MAX_Q" - "nvml", "PowerProfileType", "MAX_Q_1", "POWER_PROFILE_MAX_Q_1" - "nvml", "PowerProfileType", "MAX_Q_2", "POWER_PROFILE_MAX_Q_2" - "nvml", "PowerProfileType", "MAX_Q_3", "POWER_PROFILE_MAX_Q_3" - "nvml", "PowerProfileType", "MEDIUM_THROUGHPUT_INFERENCE", "POWER_PROFILE_MEDIUM_THROUGHPUT_INFERENCE" - "nvml", "PowerProfileType", "MEMORY_BOUND", "POWER_PROFILE_MEMORY_BOUND" - "nvml", "PowerProfileType", "MIG", "POWER_PROFILE_MIG" - "nvml", "PowerProfileType", "NETWORK", "POWER_PROFILE_NETWORK" - "nvml", "PowerProfileType", "NETWORK_BOUND", "POWER_PROFILE_NETWORK_BOUND" - "nvml", "PowerProfileType", "RBM", "POWER_PROFILE_RBM" - "nvml", "PowerProfileType", "SYNC_BALANCED", "POWER_PROFILE_SYNC_BALANCED" - "nvml", "PowerProfileType", "TRAINING", "POWER_PROFILE_TRAINING" - "nvml", "TemperatureSensors", "COUNT", "TEMPERATURE_COUNT" - "nvml", "TemperatureSensors", "GPU", "TEMPERATURE_GPU" - "nvml", "TemperatureSensors", "GPU_MAX", "TEMPERATURE_GPU_MAX" - "nvml", "TemperatureThresholds", "ACOUSTIC_CURR", "TEMPERATURE_THRESHOLD_ACOUSTIC_CURR" - "nvml", "TemperatureThresholds", "ACOUSTIC_MAX", "TEMPERATURE_THRESHOLD_ACOUSTIC_MAX" - "nvml", "TemperatureThresholds", "ACOUSTIC_MIN", "TEMPERATURE_THRESHOLD_ACOUSTIC_MIN" - "nvml", "TemperatureThresholds", "COUNT", "TEMPERATURE_THRESHOLD_COUNT" - "nvml", "TemperatureThresholds", "GPS_CURR", "TEMPERATURE_THRESHOLD_GPS_CURR" - "nvml", "TemperatureThresholds", "GPU_MAX", "TEMPERATURE_THRESHOLD_GPU_MAX" - "nvml", "TemperatureThresholds", "MEM_MAX", "TEMPERATURE_THRESHOLD_MEM_MAX" - "nvml", "TemperatureThresholds", "SHUTDOWN", "TEMPERATURE_THRESHOLD_SHUTDOWN" - "nvml", "TemperatureThresholds", "SLOWDOWN", "TEMPERATURE_THRESHOLD_SLOWDOWN" - "nvml", "VgpuCapability", "COUNT", "VGPU_CAP_COUNT" - "nvml", "VgpuCapability", "EXCLUSIVE_SIZE", "VGPU_CAP_EXCLUSIVE_SIZE" - "nvml", "VgpuCapability", "EXCLUSIVE_TYPE", "VGPU_CAP_EXCLUSIVE_TYPE" - "nvml", "VgpuCapability", "GPUDIRECT", "VGPU_CAP_GPUDIRECT" - "nvml", "VgpuCapability", "MULTI_VGPU_EXCLUSIVE", "VGPU_CAP_MULTI_VGPU_EXCLUSIVE" - "nvml", "VgpuCapability", "NVLINK_P2P", "VGPU_CAP_NVLINK_P2P" - "nvml", "VgpuDriverCapability", "COUNT", "VGPU_DRIVER_CAP_COUNT" - "nvml", "VgpuDriverCapability", "HETEROGENEOUS_MULTI_VGPU", "VGPU_DRIVER_CAP_HETEROGENEOUS_MULTI_VGPU" - "nvml", "VgpuDriverCapability", "WARM_UPDATE", "VGPU_DRIVER_CAP_WARM_UPDATE" - "nvml", "VgpuGuestInfoState", "INITIALIZED", "VGPU_INSTANCE_GUEST_INFO_STATE_INITIALIZED" - "nvml", "VgpuGuestInfoState", "UNINITIALIZED", "VGPU_INSTANCE_GUEST_INFO_STATE_UNINITIALIZED" - "nvml", "VgpuPgpuCompatibilityLimitCode", "GPU", "VGPU_COMPATIBILITY_LIMIT_GPU" - "nvml", "VgpuPgpuCompatibilityLimitCode", "GUEST_DRIVER", "VGPU_COMPATIBILITY_LIMIT_GUEST_DRIVER" - "nvml", "VgpuPgpuCompatibilityLimitCode", "HOST_DRIVER", "VGPU_COMPATIBILITY_LIMIT_HOST_DRIVER" - "nvml", "VgpuPgpuCompatibilityLimitCode", "NONE", "VGPU_COMPATIBILITY_LIMIT_NONE" - "nvml", "VgpuPgpuCompatibilityLimitCode", "OTHER", "VGPU_COMPATIBILITY_LIMIT_OTHER" - "nvml", "VgpuVmIdType", "DOMAIN_ID", "VGPU_VM_ID_DOMAIN_ID" - "nvml", "VgpuVmIdType", "UUID", "VGPU_VM_ID_UUID" - -- ``nvml.device_get_samples``: On a zero-sample result, the old implementation - returned a bare ``Sample`` instance instead of the documented - ``(sample_val_type, samples)`` tuple. It now always returns the correct 2-tuple. - -- ``nvml.device_get_supported_vgpus``, ``nvml.device_get_creatable_vgpus``, - ``nvml.device_get_active_vgpus``: The old implementations would return a - ``NameError`` at runtime whenever there was at least one result to return. Now - fixed. - -- ``nvml.device_get_gpu_instances``, - ``nvml.gpu_instance_get_compute_instances``: The old implementations would fail - with ``ValueError: Invalid shape in axis 0: 0`` whenever the result is empty. - Now correctly returns an empty array in that case. - -- ``nvml.device_get_vgpu_utilization``: The old implementation would only fill - in the first element of the returned array; the rest was uninitialized - garbage. It is now correctly returned as a properly-sized array. In addition, - the first element of the returned tuple, ``sample_val_type``, is now a plain - ``int`` rather than a length-1 array, since it is a single output parameter, - not a per-sample value. - -- ``nvml.device_get_vgpu_process_utilization``: The old implementation returned - a tuple ``(sample_count, samples)``, and the ``samples`` array only ever - contained one element. It is now correctly returned as a properly-sized array - (and the redundant ``sample_count`` — which was only ever the array's own size — - is no longer part of the returned value; use ``len(result)`` instead). - -- ``nvml.NvlinkFirmwareInfo``: The ``firmware_version`` member now returns only - valid entries. The ``num_valid_entries`` member is removed, as its design made - it possible to access invalid memory. - -Enhancements ------------- - -- ``cuda-bindings`` docstrings now include the full documentation from the upstream CTK, not just the first paragraph. -- The following functions now accept ``None`` for their buffer-like pointer argument (an integer address or an object supporting the buffer protocol), which is treated as ``NULL``. - - - ``cudla.module_load_from_memory`` (``p_module``) - - ``nvfatbin.add_cubin``, ``nvfatbin.add_index``, ``nvfatbin.add_ltoir``, ``nvfatbin.add_ptx``, ``nvfatbin.add_reloc``, ``nvfatbin.add_tile_ir`` (``code``) - - ``nvfatbin.get`` (``buffer``) - - ``nvjitlink.add_data`` (``data``) - - ``nvjitlink.get_error_log``, ``nvjitlink.get_info_log`` (``log``) - - ``nvjitlink.get_linked_cubin`` (``cubin``) - - ``nvjitlink.get_linked_ltoir`` (``ltoir``) - - ``nvjitlink.get_linked_ptx`` (``ptx``) - - ``nvvm.add_module_to_program``, ``nvvm.lazy_add_module_to_program``, ``nvvm.get_compiled_result``, ``nvvm.get_program_log`` (``buffer``) -- For all ``cuda-bindings`` APIs, functions that accept a struct wrapper will accept the struct wrapper directly, rather than requiring getting the ``.ptr`` property. -- Creating class instances in ``cuda-bindings`` should now be faster in most cases, because it requires 1 heap allocation rather than 2. -- Before a source build compiles anything, it now checks that the CUDA Toolkit's ``cuda.h`` has the major.minor the source tree was generated from. A mismatch stops the build with a message that names the header found and the version needed. Previously a mismatch surfaced as a long list of C++ redefinition errors. - -Behavior changes ----------------- - -- Passing a ``None`` to ``nvvm.add_module_to_program`` or ``nvvm.lazy_add_module_to_program`` will now raise a ``nvvm.nvvmError`` (``INVALID_INPUT``) rather than a ``ValueError``. - -Bugfixes --------- - -- A number of incorrect docstrings have been corrected. -- Setting a ``cuda_bindings`` class instance to itself (``obj = cuda_bindings.driver.Class(); obj[0] = obj``) will no longer crash or use garbage values. -- Fix a potential use-after-free error in the destructors of classes in ``driver`` and ``runtime``. - -Prerelease feature ------------------- - -A new version of the ``nvrtc`` API is available as ``cuda.bindings._v2.nvrtc``. The -primary improvements are: (1) raising exceptions rather than returning error -codes, (2) uses PEP8-compliant naming, and (3) more performance. This API is -still experimental and subject to change. - -Known issues ------------- - -* Updating from older versions (v12.6.2.post1 and below) via ``pip install -U cuda-python`` might not work. Please do a clean re-installation by uninstalling ``pip uninstall -y cuda-python`` followed by installing ``pip install cuda-python``. -* ``nvml.system_get_process_name`` on WSL can return incorrect values. To work around this, set the locale to "C" before calling ``nvml.device_get_compute_running_processes_v3`` (which sets the process names) and before calling ``nvml.system_get_process_name``. ``cuda_core`` does this automatically, but users of the raw NVML API will need to do this manually. diff --git a/cuda_bindings/releasenotes/cuda-header-version-check-c8eaa96d6d5ad554.yaml b/cuda_bindings/releasenotes/cuda-header-version-check-c8eaa96d6d5ad554.yaml new file mode 100644 index 00000000000..7937f497426 --- /dev/null +++ b/cuda_bindings/releasenotes/cuda-header-version-check-c8eaa96d6d5ad554.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + Before a source build compiles anything, it now checks that the CUDA Toolkit's ``cuda.h`` has + the major.minor the source tree was generated from. A mismatch stops the build with a message + that names the header found and the version needed. Previously a mismatch surfaced as a long + list of C++ redefinition errors. (#2920) diff --git a/cuda_bindings/releasenotes/destructor-use-after-free-9ec9fa7be805904f.yaml b/cuda_bindings/releasenotes/destructor-use-after-free-9ec9fa7be805904f.yaml new file mode 100644 index 00000000000..86f176a6618 --- /dev/null +++ b/cuda_bindings/releasenotes/destructor-use-after-free-9ec9fa7be805904f.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + Fix a potential use-after-free error in the destructors of classes in ``driver`` and + ``runtime``. (#2874) diff --git a/cuda_bindings/releasenotes/docstring-corrections-d8ff2a3c627df05a.yaml b/cuda_bindings/releasenotes/docstring-corrections-d8ff2a3c627df05a.yaml new file mode 100644 index 00000000000..df0acd1f2b2 --- /dev/null +++ b/cuda_bindings/releasenotes/docstring-corrections-d8ff2a3c627df05a.yaml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + A number of incorrect docstrings have been corrected. (#2820) diff --git a/cuda_bindings/releasenotes/enum-member-renames-e611ece8f9177c5b.yaml b/cuda_bindings/releasenotes/enum-member-renames-e611ece8f9177c5b.yaml new file mode 100644 index 00000000000..b608857d7a4 --- /dev/null +++ b/cuda_bindings/releasenotes/enum-member-renames-e611ece8f9177c5b.yaml @@ -0,0 +1,255 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + Many ``cuda.bindings.cudla`` and ``cuda.bindings.nvml`` enum members had a + redundant prefix stripped from their name, + e.g. ``nvml.BrandType.BRAND_GEFORCE`` is now ``nvml.BrandType.GEFORCE``. + The old, longer names remain available as backward-compatible aliases and + will continue to work. The full list of renames, excluding the 447 + similarly-renamed ``nvml.GpmMetricId`` members (which had their + ``GPM_METRIC_`` prefix stripped the same way), is below. + + .. csv-table:: Enum member renames (old names remain available as backward-compatible aliases) + :header: "Module", "Enum class", "New name", "Deprecated alias" + :widths: 10, 25, 25, 25 + + "cudla", "FenceType", "FENCE", "NVSCISYNC_FENCE" + "cudla", "FenceType", "FENCE_SOF", "NVSCISYNC_FENCE_SOF" + "cudla", "ModuleLoadFlags", "DEFAULT", "MODULE_DEFAULT" + "cudla", "ModuleLoadFlags", "ENABLE_FAULT_DIAGNOSTICS", "MODULE_ENABLE_FAULT_DIAGNOSTICS" + "cudla", "ScratchMemoryConfig", "CONFIG_MAX", "MAX" + "cudla", "ScratchMemoryConfig", "DEFAULT", "SCRATCH_MEMORY_DEFAULT" + "cudla", "ScratchMemoryConfig", "SHARED_STATIC", "SCRATCH_MEMORY_SHARED_STATIC" + "cudla", "SubmissionFlags", "DIAGNOSTICS_TASK", "SUBMIT_DIAGNOSTICS_TASK" + "cudla", "SubmissionFlags", "NOOP", "SUBMIT_NOOP" + "cudla", "SubmissionFlags", "SKIP_LOCK_ACQUIRE", "SUBMIT_SKIP_LOCK_ACQUIRE" + "nvml", "BrandType", "COUNT", "BRAND_COUNT" + "nvml", "BrandType", "GEFORCE", "BRAND_GEFORCE" + "nvml", "BrandType", "GEFORCE_RTX", "BRAND_GEFORCE_RTX" + "nvml", "BrandType", "GRID", "BRAND_GRID" + "nvml", "BrandType", "NVIDIA", "BRAND_NVIDIA" + "nvml", "BrandType", "NVIDIA_CLOUD_GAMING", "BRAND_NVIDIA_CLOUD_GAMING" + "nvml", "BrandType", "NVIDIA_DLA", "BRAND_NVIDIA_DLA" + "nvml", "BrandType", "NVIDIA_NPU", "BRAND_NVIDIA_NPU" + "nvml", "BrandType", "NVIDIA_RTX", "BRAND_NVIDIA_RTX" + "nvml", "BrandType", "NVIDIA_VAPPS", "BRAND_NVIDIA_VAPPS" + "nvml", "BrandType", "NVIDIA_VCS", "BRAND_NVIDIA_VCS" + "nvml", "BrandType", "NVIDIA_VGAMEDEV", "BRAND_NVIDIA_VGAMEDEV" + "nvml", "BrandType", "NVIDIA_VGAMING", "BRAND_NVIDIA_VGAMING" + "nvml", "BrandType", "NVIDIA_VPC", "BRAND_NVIDIA_VPC" + "nvml", "BrandType", "NVIDIA_VWS", "BRAND_NVIDIA_VWS" + "nvml", "BrandType", "NVS", "BRAND_NVS" + "nvml", "BrandType", "QUADRO", "BRAND_QUADRO" + "nvml", "BrandType", "QUADRO_RTX", "BRAND_QUADRO_RTX" + "nvml", "BrandType", "TESLA", "BRAND_TESLA" + "nvml", "BrandType", "TITAN", "BRAND_TITAN" + "nvml", "BrandType", "TITAN_RTX", "BRAND_TITAN_RTX" + "nvml", "BrandType", "UNKNOWN", "BRAND_UNKNOWN" + "nvml", "BridgeChipType", "BRO4", "BRIDGE_CHIP_BRO4" + "nvml", "BridgeChipType", "PLX", "BRIDGE_CHIP_PLX" + "nvml", "ClockType", "COUNT", "CLOCK_COUNT" + "nvml", "ClockType", "GRAPHICS", "CLOCK_GRAPHICS" + "nvml", "ClockType", "MEM", "CLOCK_MEM" + "nvml", "ClockType", "SM", "CLOCK_SM" + "nvml", "ClockType", "VIDEO", "CLOCK_VIDEO" + "nvml", "ComputeMode", "COUNT", "COMPUTEMODE_COUNT" + "nvml", "ComputeMode", "DEFAULT", "COMPUTEMODE_DEFAULT" + "nvml", "ComputeMode", "EXCLUSIVE_PROCESS", "COMPUTEMODE_EXCLUSIVE_PROCESS" + "nvml", "ComputeMode", "EXCLUSIVE_THREAD", "COMPUTEMODE_EXCLUSIVE_THREAD" + "nvml", "ComputeMode", "PROHIBITED", "COMPUTEMODE_PROHIBITED" + "nvml", "CoolerControl", "COUNT", "THERMAL_COOLER_SIGNAL_COUNT" + "nvml", "CoolerControl", "NONE", "THERMAL_COOLER_SIGNAL_NONE" + "nvml", "CoolerControl", "TOGGLE", "THERMAL_COOLER_SIGNAL_TOGGLE" + "nvml", "CoolerControl", "VARIABLE", "THERMAL_COOLER_SIGNAL_VARIABLE" + "nvml", "CoolerTarget", "GPU", "THERMAL_GPU" + "nvml", "CoolerTarget", "GPU_RELATED", "THERMAL_GPU_RELATED" + "nvml", "CoolerTarget", "MEMORY", "THERMAL_MEMORY" + "nvml", "CoolerTarget", "NONE", "THERMAL_NONE" + "nvml", "CoolerTarget", "POWER_SUPPLY", "THERMAL_POWER_SUPPLY" + "nvml", "DetachGpuState", "KEEP", "DETACH_GPU_KEEP" + "nvml", "DetachGpuState", "REMOVE", "DETACH_GPU_REMOVE" + "nvml", "DeviceAddressingModeType", "ATS", "DEVICE_ADDRESSING_MODE_ATS" + "nvml", "DeviceAddressingModeType", "HMM", "DEVICE_ADDRESSING_MODE_HMM" + "nvml", "DeviceAddressingModeType", "NONE", "DEVICE_ADDRESSING_MODE_NONE" + "nvml", "DeviceGpuRecoveryAction", "BUS_RESET", "GPU_RECOVERY_ACTION_BUS_RESET" + "nvml", "DeviceGpuRecoveryAction", "DRAIN_AND_RESET", "GPU_RECOVERY_ACTION_DRAIN_AND_RESET" + "nvml", "DeviceGpuRecoveryAction", "DRAIN_P2P", "GPU_RECOVERY_ACTION_DRAIN_P2P" + "nvml", "DeviceGpuRecoveryAction", "GPU_RESET", "GPU_RECOVERY_ACTION_GPU_RESET" + "nvml", "DeviceGpuRecoveryAction", "NODE_REBOOT", "GPU_RECOVERY_ACTION_NODE_REBOOT" + "nvml", "DeviceGpuRecoveryAction", "NONE", "GPU_RECOVERY_ACTION_NONE" + "nvml", "DeviceGpuRecoveryAction", "RECOVER_IMEX_DOMAIN", "GPU_RECOVERY_ACTION_RECOVER_IMEX_DOMAIN" + "nvml", "DeviceGpuRecoveryAction", "SYSTEM_REBOOT", "GPU_RECOVERY_ACTION_SYSTEM_REBOOT" + "nvml", "DeviceVgpuCapability", "COMPUTE_MEDIA_ENGINE_GPU", "DEVICE_VGPU_CAP_COMPUTE_MEDIA_ENGINE_GPU" + "nvml", "DeviceVgpuCapability", "COUNT", "DEVICE_VGPU_CAP_COUNT" + "nvml", "DeviceVgpuCapability", "DEVICE_STREAMING", "DEVICE_VGPU_CAP_DEVICE_STREAMING" + "nvml", "DeviceVgpuCapability", "FRACTIONAL_MULTI_VGPU", "DEVICE_VGPU_CAP_FRACTIONAL_MULTI_VGPU" + "nvml", "DeviceVgpuCapability", "HETEROGENEOUS_TIMESLICE_PROFILES", "DEVICE_VGPU_CAP_HETEROGENEOUS_TIMESLICE_PROFILES" + "nvml", "DeviceVgpuCapability", "HETEROGENEOUS_TIMESLICE_SIZES", "DEVICE_VGPU_CAP_HETEROGENEOUS_TIMESLICE_SIZES" + "nvml", "DeviceVgpuCapability", "HOMOGENEOUS_PLACEMENTS", "DEVICE_VGPU_CAP_HOMOGENEOUS_PLACEMENTS" + "nvml", "DeviceVgpuCapability", "MIG_TIMESLICING_ENABLED", "DEVICE_VGPU_CAP_MIG_TIMESLICING_ENABLED" + "nvml", "DeviceVgpuCapability", "MIG_TIMESLICING_SUPPORTED", "DEVICE_VGPU_CAP_MIG_TIMESLICING_SUPPORTED" + "nvml", "DeviceVgpuCapability", "MINI_QUARTER_GPU", "DEVICE_VGPU_CAP_MINI_QUARTER_GPU" + "nvml", "DeviceVgpuCapability", "READ_DEVICE_BUFFER_BW", "DEVICE_VGPU_CAP_READ_DEVICE_BUFFER_BW" + "nvml", "DeviceVgpuCapability", "WARM_UPDATE", "DEVICE_VGPU_CAP_WARM_UPDATE" + "nvml", "DeviceVgpuCapability", "WRITE_DEVICE_BUFFER_BW", "DEVICE_VGPU_CAP_WRITE_DEVICE_BUFFER_BW" + "nvml", "DriverModel", "MCDM", "DRIVER_MCDM" + "nvml", "DriverModel", "WDDM", "DRIVER_WDDM" + "nvml", "DriverModel", "WDM", "DRIVER_WDM" + "nvml", "EccCounterType", "ECC_COUNTER_TYPE_COUNT", "COUNT" + "nvml", "EnableState", "DISABLED", "FEATURE_DISABLED" + "nvml", "EnableState", "ENABLED", "FEATURE_ENABLED" + "nvml", "EncoderType", "AV1", "ENCODER_QUERY_AV1" + "nvml", "EncoderType", "H264", "ENCODER_QUERY_H264" + "nvml", "EncoderType", "HEVC", "ENCODER_QUERY_HEVC" + "nvml", "EncoderType", "UNKNOWN", "ENCODER_QUERY_UNKNOWN" + "nvml", "EventDataType", "NVML_EVENT", "EVENT" + "nvml", "FanState", "FAILED", "FAN_FAILED" + "nvml", "FanState", "NORMAL", "FAN_NORMAL" + "nvml", "GpuOperationMode", "ALL_ON", "GOM_ALL_ON" + "nvml", "GpuOperationMode", "COMPUTE", "GOM_COMPUTE" + "nvml", "GpuOperationMode", "LOW_DP", "GOM_LOW_DP" + "nvml", "GpuP2PCapsIndex", "ATOMICS", "P2P_CAPS_INDEX_ATOMICS" + "nvml", "GpuP2PCapsIndex", "NVLINK", "P2P_CAPS_INDEX_NVLINK" + "nvml", "GpuP2PCapsIndex", "PCI", "P2P_CAPS_INDEX_PCI" + "nvml", "GpuP2PCapsIndex", "PROP", "P2P_CAPS_INDEX_PROP" + "nvml", "GpuP2PCapsIndex", "READ", "P2P_CAPS_INDEX_READ" + "nvml", "GpuP2PCapsIndex", "UNKNOWN", "P2P_CAPS_INDEX_UNKNOWN" + "nvml", "GpuP2PCapsIndex", "WRITE", "P2P_CAPS_INDEX_WRITE" + "nvml", "GpuP2PStatus", "CHIPSET_NOT_SUPPORED", "P2P_STATUS_CHIPSET_NOT_SUPPORED" + "nvml", "GpuP2PStatus", "CHIPSET_NOT_SUPPORTED", "P2P_STATUS_CHIPSET_NOT_SUPPORTED" + "nvml", "GpuP2PStatus", "DISABLED_BY_REGKEY", "P2P_STATUS_DISABLED_BY_REGKEY" + "nvml", "GpuP2PStatus", "GPU_NOT_SUPPORTED", "P2P_STATUS_GPU_NOT_SUPPORTED" + "nvml", "GpuP2PStatus", "IOH_TOPOLOGY_NOT_SUPPORTED", "P2P_STATUS_IOH_TOPOLOGY_NOT_SUPPORTED" + "nvml", "GpuP2PStatus", "NOT_SUPPORTED", "P2P_STATUS_NOT_SUPPORTED" + "nvml", "GpuP2PStatus", "OK", "P2P_STATUS_OK" + "nvml", "GpuP2PStatus", "UNKNOWN", "P2P_STATUS_UNKNOWN" + "nvml", "GpuTopologyLevel", "HOSTBRIDGE", "TOPOLOGY_HOSTBRIDGE" + "nvml", "GpuTopologyLevel", "INTERNAL", "TOPOLOGY_INTERNAL" + "nvml", "GpuTopologyLevel", "MULTIPLE", "TOPOLOGY_MULTIPLE" + "nvml", "GpuTopologyLevel", "NODE", "TOPOLOGY_NODE" + "nvml", "GpuTopologyLevel", "SINGLE", "TOPOLOGY_SINGLE" + "nvml", "GpuTopologyLevel", "SYSTEM", "TOPOLOGY_SYSTEM" + "nvml", "GpuUtilizationDomainId", "BUS", "GPU_UTILIZATION_DOMAIN_BUS" + "nvml", "GpuUtilizationDomainId", "FB", "GPU_UTILIZATION_DOMAIN_FB" + "nvml", "GpuUtilizationDomainId", "GPU", "GPU_UTILIZATION_DOMAIN_GPU" + "nvml", "GpuUtilizationDomainId", "VID", "GPU_UTILIZATION_DOMAIN_VID" + "nvml", "InforomObject", "COUNT", "INFOROM_COUNT" + "nvml", "InforomObject", "DEN", "INFOROM_DEN" + "nvml", "InforomObject", "ECC", "INFOROM_ECC" + "nvml", "InforomObject", "OEM", "INFOROM_OEM" + "nvml", "InforomObject", "POWER", "INFOROM_POWER" + "nvml", "IntNvLinkDeviceType", "GPU", "NVLINK_DEVICE_TYPE_GPU" + "nvml", "IntNvLinkDeviceType", "IBMNPU", "NVLINK_DEVICE_TYPE_IBMNPU" + "nvml", "IntNvLinkDeviceType", "SWITCH", "NVLINK_DEVICE_TYPE_SWITCH" + "nvml", "IntNvLinkDeviceType", "UNKNOWN", "NVLINK_DEVICE_TYPE_UNKNOWN" + "nvml", "NvLinkCapability", "COUNT", "NVLINK_CAP_COUNT" + "nvml", "NvLinkCapability", "P2P_ATOMICS", "NVLINK_CAP_P2P_ATOMICS" + "nvml", "NvLinkCapability", "P2P_SUPPORTED", "NVLINK_CAP_P2P_SUPPORTED" + "nvml", "NvLinkCapability", "SLI_BRIDGE", "NVLINK_CAP_SLI_BRIDGE" + "nvml", "NvLinkCapability", "SYSMEM_ACCESS", "NVLINK_CAP_SYSMEM_ACCESS" + "nvml", "NvLinkCapability", "SYSMEM_ATOMICS", "NVLINK_CAP_SYSMEM_ATOMICS" + "nvml", "NvLinkCapability", "VALID", "NVLINK_CAP_VALID" + "nvml", "NvLinkErrorCounter", "COUNT", "NVLINK_ERROR_COUNT" + "nvml", "NvLinkErrorCounter", "DL_CRC_DATA", "NVLINK_ERROR_DL_CRC_DATA" + "nvml", "NvLinkErrorCounter", "DL_CRC_FLIT", "NVLINK_ERROR_DL_CRC_FLIT" + "nvml", "NvLinkErrorCounter", "DL_ECC_DATA", "NVLINK_ERROR_DL_ECC_DATA" + "nvml", "NvLinkErrorCounter", "DL_RECOVERY", "NVLINK_ERROR_DL_RECOVERY" + "nvml", "NvLinkErrorCounter", "DL_REPLAY", "NVLINK_ERROR_DL_REPLAY" + "nvml", "NvLinkUtilizationCountPktTypes", "ALL", "NVLINK_COUNTER_PKTFILTER_ALL" + "nvml", "NvLinkUtilizationCountPktTypes", "FLUSH", "NVLINK_COUNTER_PKTFILTER_FLUSH" + "nvml", "NvLinkUtilizationCountPktTypes", "NOP", "NVLINK_COUNTER_PKTFILTER_NOP" + "nvml", "NvLinkUtilizationCountPktTypes", "NRATOM", "NVLINK_COUNTER_PKTFILTER_NRATOM" + "nvml", "NvLinkUtilizationCountPktTypes", "RATOM", "NVLINK_COUNTER_PKTFILTER_RATOM" + "nvml", "NvLinkUtilizationCountPktTypes", "READ", "NVLINK_COUNTER_PKTFILTER_READ" + "nvml", "NvLinkUtilizationCountPktTypes", "RESPDATA", "NVLINK_COUNTER_PKTFILTER_RESPDATA" + "nvml", "NvLinkUtilizationCountPktTypes", "RESPNODATA", "NVLINK_COUNTER_PKTFILTER_RESPNODATA" + "nvml", "NvLinkUtilizationCountPktTypes", "WRITE", "NVLINK_COUNTER_PKTFILTER_WRITE" + "nvml", "NvLinkUtilizationCountUnits", "BYTES", "NVLINK_COUNTER_UNIT_BYTES" + "nvml", "NvLinkUtilizationCountUnits", "COUNT", "NVLINK_COUNTER_UNIT_COUNT" + "nvml", "NvLinkUtilizationCountUnits", "CYCLES", "NVLINK_COUNTER_UNIT_CYCLES" + "nvml", "NvLinkUtilizationCountUnits", "PACKETS", "NVLINK_COUNTER_UNIT_PACKETS" + "nvml", "NvLinkUtilizationCountUnits", "RESERVED", "NVLINK_COUNTER_UNIT_RESERVED" + "nvml", "NvlinkVersion", "NVLINK_VERSION_1_0", "VERSION_1_0" + "nvml", "NvlinkVersion", "NVLINK_VERSION_2_0", "VERSION_2_0" + "nvml", "NvlinkVersion", "NVLINK_VERSION_2_2", "VERSION_2_2" + "nvml", "NvlinkVersion", "NVLINK_VERSION_3_0", "VERSION_3_0" + "nvml", "NvlinkVersion", "NVLINK_VERSION_3_1", "VERSION_3_1" + "nvml", "NvlinkVersion", "NVLINK_VERSION_4_0", "VERSION_4_0" + "nvml", "NvlinkVersion", "NVLINK_VERSION_5_0", "VERSION_5_0" + "nvml", "NvlinkVersion", "NVLINK_VERSION_6_0", "VERSION_6_0" + "nvml", "NvlinkVersion", "NVLINK_VERSION_INVALID", "VERSION_INVALID" + "nvml", "PcieLinkState", "KEEP", "PCIE_LINK_KEEP" + "nvml", "PcieLinkState", "SHUT_DOWN", "PCIE_LINK_SHUT_DOWN" + "nvml", "PcieUtilCounter", "COUNT", "PCIE_UTIL_COUNT" + "nvml", "PcieUtilCounter", "RX_BYTES", "PCIE_UTIL_RX_BYTES" + "nvml", "PcieUtilCounter", "TX_BYTES", "PCIE_UTIL_TX_BYTES" + "nvml", "PerfPolicyType", "BOARD_LIMIT", "PERF_POLICY_BOARD_LIMIT" + "nvml", "PerfPolicyType", "COUNT", "PERF_POLICY_COUNT" + "nvml", "PerfPolicyType", "LOW_UTILIZATION", "PERF_POLICY_LOW_UTILIZATION" + "nvml", "PerfPolicyType", "POWER", "PERF_POLICY_POWER" + "nvml", "PerfPolicyType", "RELIABILITY", "PERF_POLICY_RELIABILITY" + "nvml", "PerfPolicyType", "SYNC_BOOST", "PERF_POLICY_SYNC_BOOST" + "nvml", "PerfPolicyType", "THERMAL", "PERF_POLICY_THERMAL" + "nvml", "PerfPolicyType", "TOTAL_APP_CLOCKS", "PERF_POLICY_TOTAL_APP_CLOCKS" + "nvml", "PerfPolicyType", "TOTAL_BASE_CLOCKS", "PERF_POLICY_TOTAL_BASE_CLOCKS" + "nvml", "PowerProfileType", "BALANCED", "POWER_PROFILE_BALANCED" + "nvml", "PowerProfileType", "COMPUTE", "POWER_PROFILE_COMPUTE" + "nvml", "PowerProfileType", "DCPCIE", "POWER_PROFILE_DCPCIE" + "nvml", "PowerProfileType", "HIGH_THROUGHPUT_INFERENCE", "POWER_PROFILE_HIGH_THROUGHPUT_INFERENCE" + "nvml", "PowerProfileType", "HMMA_DENSE", "POWER_PROFILE_HMMA_DENSE" + "nvml", "PowerProfileType", "HMMA_SPARSE", "POWER_PROFILE_HMMA_SPARSE" + "nvml", "PowerProfileType", "HPC", "POWER_PROFILE_HPC" + "nvml", "PowerProfileType", "INFERENCE", "POWER_PROFILE_INFERENCE" + "nvml", "PowerProfileType", "LLM_INFERENCE", "POWER_PROFILE_LLM_INFERENCE" + "nvml", "PowerProfileType", "LLM_TRAINING", "POWER_PROFILE_LLM_TRAINING" + "nvml", "PowerProfileType", "LOW_LATENCY_INFERENCE", "POWER_PROFILE_LOW_LATENCY_INFERENCE" + "nvml", "PowerProfileType", "LOW_PRIORITY_BACKGROUND", "POWER_PROFILE_LOW_PRIORITY_BACKGROUND" + "nvml", "PowerProfileType", "MAX", "POWER_PROFILE_MAX" + "nvml", "PowerProfileType", "MAX_P", "POWER_PROFILE_MAX_P" + "nvml", "PowerProfileType", "MAX_Q", "POWER_PROFILE_MAX_Q" + "nvml", "PowerProfileType", "MAX_Q_1", "POWER_PROFILE_MAX_Q_1" + "nvml", "PowerProfileType", "MAX_Q_2", "POWER_PROFILE_MAX_Q_2" + "nvml", "PowerProfileType", "MAX_Q_3", "POWER_PROFILE_MAX_Q_3" + "nvml", "PowerProfileType", "MEDIUM_THROUGHPUT_INFERENCE", "POWER_PROFILE_MEDIUM_THROUGHPUT_INFERENCE" + "nvml", "PowerProfileType", "MEMORY_BOUND", "POWER_PROFILE_MEMORY_BOUND" + "nvml", "PowerProfileType", "MIG", "POWER_PROFILE_MIG" + "nvml", "PowerProfileType", "NETWORK", "POWER_PROFILE_NETWORK" + "nvml", "PowerProfileType", "NETWORK_BOUND", "POWER_PROFILE_NETWORK_BOUND" + "nvml", "PowerProfileType", "RBM", "POWER_PROFILE_RBM" + "nvml", "PowerProfileType", "SYNC_BALANCED", "POWER_PROFILE_SYNC_BALANCED" + "nvml", "PowerProfileType", "TRAINING", "POWER_PROFILE_TRAINING" + "nvml", "TemperatureSensors", "COUNT", "TEMPERATURE_COUNT" + "nvml", "TemperatureSensors", "GPU", "TEMPERATURE_GPU" + "nvml", "TemperatureSensors", "GPU_MAX", "TEMPERATURE_GPU_MAX" + "nvml", "TemperatureThresholds", "ACOUSTIC_CURR", "TEMPERATURE_THRESHOLD_ACOUSTIC_CURR" + "nvml", "TemperatureThresholds", "ACOUSTIC_MAX", "TEMPERATURE_THRESHOLD_ACOUSTIC_MAX" + "nvml", "TemperatureThresholds", "ACOUSTIC_MIN", "TEMPERATURE_THRESHOLD_ACOUSTIC_MIN" + "nvml", "TemperatureThresholds", "COUNT", "TEMPERATURE_THRESHOLD_COUNT" + "nvml", "TemperatureThresholds", "GPS_CURR", "TEMPERATURE_THRESHOLD_GPS_CURR" + "nvml", "TemperatureThresholds", "GPU_MAX", "TEMPERATURE_THRESHOLD_GPU_MAX" + "nvml", "TemperatureThresholds", "MEM_MAX", "TEMPERATURE_THRESHOLD_MEM_MAX" + "nvml", "TemperatureThresholds", "SHUTDOWN", "TEMPERATURE_THRESHOLD_SHUTDOWN" + "nvml", "TemperatureThresholds", "SLOWDOWN", "TEMPERATURE_THRESHOLD_SLOWDOWN" + "nvml", "VgpuCapability", "COUNT", "VGPU_CAP_COUNT" + "nvml", "VgpuCapability", "EXCLUSIVE_SIZE", "VGPU_CAP_EXCLUSIVE_SIZE" + "nvml", "VgpuCapability", "EXCLUSIVE_TYPE", "VGPU_CAP_EXCLUSIVE_TYPE" + "nvml", "VgpuCapability", "GPUDIRECT", "VGPU_CAP_GPUDIRECT" + "nvml", "VgpuCapability", "MULTI_VGPU_EXCLUSIVE", "VGPU_CAP_MULTI_VGPU_EXCLUSIVE" + "nvml", "VgpuCapability", "NVLINK_P2P", "VGPU_CAP_NVLINK_P2P" + "nvml", "VgpuDriverCapability", "COUNT", "VGPU_DRIVER_CAP_COUNT" + "nvml", "VgpuDriverCapability", "HETEROGENEOUS_MULTI_VGPU", "VGPU_DRIVER_CAP_HETEROGENEOUS_MULTI_VGPU" + "nvml", "VgpuDriverCapability", "WARM_UPDATE", "VGPU_DRIVER_CAP_WARM_UPDATE" + "nvml", "VgpuGuestInfoState", "INITIALIZED", "VGPU_INSTANCE_GUEST_INFO_STATE_INITIALIZED" + "nvml", "VgpuGuestInfoState", "UNINITIALIZED", "VGPU_INSTANCE_GUEST_INFO_STATE_UNINITIALIZED" + "nvml", "VgpuPgpuCompatibilityLimitCode", "GPU", "VGPU_COMPATIBILITY_LIMIT_GPU" + "nvml", "VgpuPgpuCompatibilityLimitCode", "GUEST_DRIVER", "VGPU_COMPATIBILITY_LIMIT_GUEST_DRIVER" + "nvml", "VgpuPgpuCompatibilityLimitCode", "HOST_DRIVER", "VGPU_COMPATIBILITY_LIMIT_HOST_DRIVER" + "nvml", "VgpuPgpuCompatibilityLimitCode", "NONE", "VGPU_COMPATIBILITY_LIMIT_NONE" + "nvml", "VgpuPgpuCompatibilityLimitCode", "OTHER", "VGPU_COMPATIBILITY_LIMIT_OTHER" + "nvml", "VgpuVmIdType", "DOMAIN_ID", "VGPU_VM_ID_DOMAIN_ID" + "nvml", "VgpuVmIdType", "UUID", "VGPU_VM_ID_UUID" + + (#2822) diff --git a/cuda_bindings/releasenotes/faster-instance-creation-ef8fc4969e39029f.yaml b/cuda_bindings/releasenotes/faster-instance-creation-ef8fc4969e39029f.yaml new file mode 100644 index 00000000000..b6a0bd07f38 --- /dev/null +++ b/cuda_bindings/releasenotes/faster-instance-creation-ef8fc4969e39029f.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + Creating class instances in ``cuda-bindings`` should now be faster in most cases, because it + requires 1 heap allocation rather than 2. (#2821) diff --git a/cuda_bindings/releasenotes/full-ctk-docstrings-d590a2c344c1e362.yaml b/cuda_bindings/releasenotes/full-ctk-docstrings-d590a2c344c1e362.yaml new file mode 100644 index 00000000000..2e376204354 --- /dev/null +++ b/cuda_bindings/releasenotes/full-ctk-docstrings-d590a2c344c1e362.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + ``cuda-bindings`` docstrings now include the full documentation from the upstream CTK, not just + the first paragraph. (#2816) diff --git a/cuda_bindings/releasenotes/none-for-buffer-pointers-51f32eceb1de8bc2.yaml b/cuda_bindings/releasenotes/none-for-buffer-pointers-51f32eceb1de8bc2.yaml new file mode 100644 index 00000000000..2b04ba39240 --- /dev/null +++ b/cuda_bindings/releasenotes/none-for-buffer-pointers-51f32eceb1de8bc2.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + The following functions now accept ``None`` for their buffer-like pointer argument (an integer + address or an object supporting the buffer protocol), which is treated as ``NULL``. + + - ``cudla.module_load_from_memory`` (``p_module``) + - ``nvfatbin.add_cubin``, ``nvfatbin.add_index``, ``nvfatbin.add_ltoir``, ``nvfatbin.add_ptx``, + ``nvfatbin.add_reloc``, ``nvfatbin.add_tile_ir`` (``code``) + - ``nvfatbin.get`` (``buffer``) + - ``nvjitlink.add_data`` (``data``) + - ``nvjitlink.get_error_log``, ``nvjitlink.get_info_log`` (``log``) + - ``nvjitlink.get_linked_cubin`` (``cubin``) + - ``nvjitlink.get_linked_ltoir`` (``ltoir``) + - ``nvjitlink.get_linked_ptx`` (``ptx``) + - ``nvvm.add_module_to_program``, ``nvvm.lazy_add_module_to_program``, + ``nvvm.get_compiled_result``, ``nvvm.get_program_log`` (``buffer``) + + (#2818) diff --git a/cuda_bindings/releasenotes/nvml-device-get-samples-e9a7a9c65a48d5db.yaml b/cuda_bindings/releasenotes/nvml-device-get-samples-e9a7a9c65a48d5db.yaml new file mode 100644 index 00000000000..e4ed25f4c1a --- /dev/null +++ b/cuda_bindings/releasenotes/nvml-device-get-samples-e9a7a9c65a48d5db.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + ``nvml.device_get_samples``: On a zero-sample result, the old implementation + returned a bare ``Sample`` instance instead of the documented + ``(sample_val_type, samples)`` tuple. It now always returns the correct 2-tuple. (#2862) diff --git a/cuda_bindings/releasenotes/nvml-empty-instance-arrays-c95161364856ba28.yaml b/cuda_bindings/releasenotes/nvml-empty-instance-arrays-c95161364856ba28.yaml new file mode 100644 index 00000000000..973bd013c2a --- /dev/null +++ b/cuda_bindings/releasenotes/nvml-empty-instance-arrays-c95161364856ba28.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + ``nvml.device_get_gpu_instances``, + ``nvml.gpu_instance_get_compute_instances``: The old implementations would fail + with ``ValueError: Invalid shape in axis 0: 0`` whenever the result is empty. + Now correctly returns an empty array in that case. (#2862) diff --git a/cuda_bindings/releasenotes/nvml-nvlink-firmware-info-9724bd9a0d08fb5a.yaml b/cuda_bindings/releasenotes/nvml-nvlink-firmware-info-9724bd9a0d08fb5a.yaml new file mode 100644 index 00000000000..63f7260ff5e --- /dev/null +++ b/cuda_bindings/releasenotes/nvml-nvlink-firmware-info-9724bd9a0d08fb5a.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + ``nvml.NvlinkFirmwareInfo``: The ``firmware_version`` member now returns only + valid entries. The ``num_valid_entries`` member is removed, as its design made + it possible to access invalid memory. (#2953) diff --git a/cuda_bindings/releasenotes/nvml-vgpu-getters-9e335e78aaa033d8.yaml b/cuda_bindings/releasenotes/nvml-vgpu-getters-9e335e78aaa033d8.yaml new file mode 100644 index 00000000000..6d56ba7f8b2 --- /dev/null +++ b/cuda_bindings/releasenotes/nvml-vgpu-getters-9e335e78aaa033d8.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + ``nvml.device_get_supported_vgpus``, ``nvml.device_get_creatable_vgpus``, + ``nvml.device_get_active_vgpus``: The old implementations would return a + ``NameError`` at runtime whenever there was at least one result to return. Now + fixed. (#2862) diff --git a/cuda_bindings/releasenotes/nvml-vgpu-process-utilization-5e4d031c0f7a6501.yaml b/cuda_bindings/releasenotes/nvml-vgpu-process-utilization-5e4d031c0f7a6501.yaml new file mode 100644 index 00000000000..2329ab22d2b --- /dev/null +++ b/cuda_bindings/releasenotes/nvml-vgpu-process-utilization-5e4d031c0f7a6501.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + ``nvml.device_get_vgpu_process_utilization``: The old implementation returned + a tuple ``(sample_count, samples)``, and the ``samples`` array only ever + contained one element. It is now correctly returned as a properly-sized array + (and the redundant ``sample_count`` — which was only ever the array's own size — + is no longer part of the returned value; use ``len(result)`` instead). (#2862) diff --git a/cuda_bindings/releasenotes/nvml-vgpu-utilization-4ac700ba248397ac.yaml b/cuda_bindings/releasenotes/nvml-vgpu-utilization-4ac700ba248397ac.yaml new file mode 100644 index 00000000000..c13fe213f89 --- /dev/null +++ b/cuda_bindings/releasenotes/nvml-vgpu-utilization-4ac700ba248397ac.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + ``nvml.device_get_vgpu_utilization``: The old implementation would only fill + in the first element of the returned array; the rest was uninitialized + garbage. It is now correctly returned as a properly-sized array. In addition, + the first element of the returned tuple, ``sample_val_type``, is now a plain + ``int`` rather than a length-1 array, since it is a single output parameter, + not a per-sample value. (#2862) diff --git a/cuda_bindings/releasenotes/nvrtc-v2-experimental-d5df4ca8d6654ae8.yaml b/cuda_bindings/releasenotes/nvrtc-v2-experimental-d5df4ca8d6654ae8.yaml new file mode 100644 index 00000000000..7958316bb81 --- /dev/null +++ b/cuda_bindings/releasenotes/nvrtc-v2-experimental-d5df4ca8d6654ae8.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + Experimental: A new version of the ``nvrtc`` API is available as ``cuda.bindings._v2.nvrtc``. + The primary improvements are: (1) raising exceptions rather than returning error codes, (2) uses + PEP8-compliant naming, and (3) more performance. This API is still experimental and subject to + change. (#2398) diff --git a/cuda_bindings/releasenotes/nvvm-none-raises-0c0349a51ae2eb38.yaml b/cuda_bindings/releasenotes/nvvm-none-raises-0c0349a51ae2eb38.yaml new file mode 100644 index 00000000000..6b310b22ff0 --- /dev/null +++ b/cuda_bindings/releasenotes/nvvm-none-raises-0c0349a51ae2eb38.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + Passing a ``None`` to ``nvvm.add_module_to_program`` or ``nvvm.lazy_add_module_to_program`` will + now raise a ``nvvm.nvvmError`` (``INVALID_INPUT``) rather than a ``ValueError``. (#2818) diff --git a/cuda_bindings/releasenotes/pip-upgrade-from-old-versions-0cef57de3b6140bd.yaml b/cuda_bindings/releasenotes/pip-upgrade-from-old-versions-0cef57de3b6140bd.yaml new file mode 100644 index 00000000000..d35c455e3a0 --- /dev/null +++ b/cuda_bindings/releasenotes/pip-upgrade-from-old-versions-0cef57de3b6140bd.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +issues: + - | + Updating from older versions (v12.6.2.post1 and below) via ``pip install -U cuda-python`` might + not work. Please do a clean re-installation by uninstalling ``pip uninstall -y cuda-python`` + followed by installing ``pip install cuda-python``. (#626) diff --git a/cuda_bindings/releasenotes/self-assignment-crash-ad099eeee3424651.yaml b/cuda_bindings/releasenotes/self-assignment-crash-ad099eeee3424651.yaml new file mode 100644 index 00000000000..0e90ab2c6ee --- /dev/null +++ b/cuda_bindings/releasenotes/self-assignment-crash-ad099eeee3424651.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + Setting a ``cuda_bindings`` class instance to itself + (``obj = cuda_bindings.driver.Class(); obj[0] = obj``) will no longer crash or use garbage + values. (#2821) diff --git a/cuda_bindings/releasenotes/struct-wrappers-direct-f525a3c7ed913e3a.yaml b/cuda_bindings/releasenotes/struct-wrappers-direct-f525a3c7ed913e3a.yaml new file mode 100644 index 00000000000..58554729bb2 --- /dev/null +++ b/cuda_bindings/releasenotes/struct-wrappers-direct-f525a3c7ed913e3a.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + For all ``cuda-bindings`` APIs, functions that accept a struct wrapper will accept the struct + wrapper directly, rather than requiring getting the ``.ptr`` property. (#2819) diff --git a/cuda_bindings/releasenotes/wsl-process-name-df799a038280be7e.yaml b/cuda_bindings/releasenotes/wsl-process-name-df799a038280be7e.yaml new file mode 100644 index 00000000000..e16a5559797 --- /dev/null +++ b/cuda_bindings/releasenotes/wsl-process-name-df799a038280be7e.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +issues: + - | + ``nvml.system_get_process_name`` on WSL can return incorrect values. To work around this, set + the locale to "C" before calling ``nvml.device_get_compute_running_processes_v3`` (which sets + the process names) and before calling ``nvml.system_get_process_name``. ``cuda_core`` does this + automatically, but users of the raw NVML API will need to do this manually. (#2118) diff --git a/cuda_core/AGENTS.md b/cuda_core/AGENTS.md index 41f90708271..156a4ab06e0 100644 --- a/cuda_core/AGENTS.md +++ b/cuda_core/AGENTS.md @@ -61,8 +61,8 @@ a bump. To bump: pre-commit hook `check-cuda-core-bindings-floor` (`toolshed/check_cuda_core_bindings_floor.py`) checks the pins. It also checks that no documentation page spells a floor out by hand. -3. Add to the release notes a "Breaking Changes" entry that names the new - floors. The support-policy table in `docs/source/support.rst` reads the +3. Add a release note (an `upgrade` entry, see "Release notes" below) that + names the new floors. The support-policy table in `docs/source/support.rst` reads the floors and the release version at docs-build time. It needs no edit. 4. If the pins moved past what the pixi lock files resolve, refresh the lock files. @@ -335,3 +335,19 @@ so that they are documented but don't appear in the main index. ### API stability Reviews should point out where existing public APIs are broken. + +## Release notes + +- Notes for 1.3.0 and later are per-change files in `releasenotes/`; do not add + hand-written pages to `docs/source/release/` for new releases. +- Changes to `cuda/`, `pyproject.toml`, `setup.py`, `build_hooks.py` or + `MANIFEST.in` need a note, unless the PR has the `skip-release-note` label. + Create one with + `python toolshed/add_note.py cuda-core `. Breaking + changes go under `upgrade`. +- Entries use `cuda.core` as the current module, so write `:class:`Device``, not + `:class:`cuda.core.Device``. +- Do not add PR links to notes (they are added automatically) and do not + rename merged notes. `issues` (known issues) entries are repeated on every + later release until deleted. See the "Release notes" section of the top-level + `CONTRIBUTING.md`. diff --git a/cuda_core/docs/source/conf.py b/cuda_core/docs/source/conf.py index 4e73974e167..8493f1760f0 100644 --- a/cuda_core/docs/source/conf.py +++ b/cuda_core/docs/source/conf.py @@ -16,6 +16,7 @@ sys.path.insert(0, str((Path(__file__).parents[3] / "cuda_python" / "docs" / "exts").absolute())) +from release_ranges import PACKAGES, format_version # -- cuda-bindings floors ---------------------------------------------------- @@ -92,9 +93,19 @@ def _html_baseurl(): "sphinx_toolbox.more_autodoc.autoprotocol", "release_toc", "release_date", + "release_notes", "enum_documenter", ] +# Release notes from 1.3.0 on are built from the notes in +# cuda_core/releasenotes; earlier ones are hand-written pages in release/. +release_notes_dir = PACKAGES["cuda-core"].notes_dir +release_notes_tag_prefix = PACKAGES["cuda-core"].tag_prefix +release_notes_first_version = format_version(PACKAGES["cuda-core"].first_version) +release_notes_title = "``cuda.core`` {version} Release Notes" +release_notes_unreleased_title = "``cuda.core`` unreleased changes" +release_notes_prolog = ".. currentmodule:: cuda.core" + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/cuda_core/docs/source/release/.gitignore b/cuda_core/docs/source/release/.gitignore new file mode 100644 index 00000000000..58a924d1868 --- /dev/null +++ b/cuda_core/docs/source/release/.gitignore @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Pages generated by cuda_python/docs/exts/release_notes.py, which covers +# releases from the first reno version on (see release_ranges.PACKAGES). Releases +# before it keep their hand-written pages here. +/unreleased-notes.rst +/1.[3-9]*-notes.rst +/1.[1-9][0-9]*-notes.rst +/[2-9]*-notes.rst +/[1-9][0-9]*-notes.rst diff --git a/cuda_core/docs/source/release/1.3.0-notes.rst b/cuda_core/docs/source/release/1.3.0-notes.rst deleted file mode 100644 index 81321e71006..00000000000 --- a/cuda_core/docs/source/release/1.3.0-notes.rst +++ /dev/null @@ -1,259 +0,0 @@ -.. SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -.. SPDX-License-Identifier: Apache-2.0 - -.. currentmodule:: cuda.core - -``cuda.core`` 1.3.0 Release Notes -================================== - -Breaking Changes ----------------- - -- ``cuda.core`` now requires a minimum ``cuda-bindings`` version per CUDA major, at build and run - time: 12.9.8 for CUDA 12 and 13.4.1 for CUDA 13. See the - :ref:`support policy `. ``import cuda.core`` with an older - ``cuda-bindings`` fails with a message that names the required version and how to install it. - ``pip install cuda-core[cu12]`` or ``pip install cuda-core[cu13]`` installs a compatible - version. Supported CUDA drivers and CUDA Toolkit libraries are unchanged. Previously - ``cuda.core`` accepted any ``cuda-bindings`` of the right major, and an older one produced import - errors for missing C functions, silently disabled features, or crashes. - (https://github.com/NVIDIA/cuda-python/issues/2783) - -- A source build of ``cuda.core`` must use a ``cuda.h`` of the same major.minor as its - ``cuda-bindings``. If they differ, the build fails early. - (https://github.com/NVIDIA/cuda-python/issues/2783) - -- With the ``cuda-bindings`` floor in place, whether a feature is available now depends only on - the CUDA driver and the ``cuda.core`` build's CUDA major. The checks that also inspected the - ``cuda-bindings`` version are gone, and so are their error messages. Error messages that name a - minimum now name a driver version. ``cuda.core.system`` always uses NVML through - ``cuda-bindings``, so ``cuda.core.system.CUDA_BINDINGS_NVML_IS_COMPATIBLE`` is always ``True``. - :mod:`cuda.core.checkpoint` requires the CUDA 13 build of ``cuda.core`` and now says so. That - requirement was already in effect, because the CUDA 12 ``cuda-bindings`` lack a type it uses. - (https://github.com/NVIDIA/cuda-python/issues/2783) - -- The C++ layer calls the driver through the entry points ``cuda-bindings`` resolves rather than - through its Cython wrappers. As a result, a driver function that the installed driver lacks can - no longer surface as ``SystemError`` from a C++ call. - (https://github.com/NVIDIA/cuda-python/issues/2783) - -New features ------------- - -- Added incremental linking to :class:`Linker` through - ``LinkerOptions(incremental=True)`` on nvJitLink 13.2 or newer. A partial - native result is returned as a CUBIN and can be passed directly to another - linker. :meth:`Linker.link` also accepts ``"ltoir"`` on nvJitLink 13.3 or - newer so incremental LTO chains can retain IR until their final link. Direct - PTX and CUBIN inputs are rejected for this target because they carry no LTOIR - and nvJitLink would otherwise silently omit them. - (`#2369 `__) - -- Added the read-only :attr:`ManagedBuffer.last_prefetch_location` property, - which reports the destination requested by the most recent explicit - prefetch across the buffer. It returns a :class:`Device` or :class:`Host`, - including NUMA-specific host locations on CUDA 13, and ``None`` if any page - has never been prefetched or the pages do not share one last-prefetch - location. This reports the requested target, not current residency or - completion of the asynchronous prefetch operation. - (`#2109 `__) - -- Added :class:`CUDAWarning`, the warning category ``cuda.core`` uses for CUDA - errors that cannot be raised, such as a failed driver call while a resource - is released by the garbage collector. Filter on it with - ``warnings.filterwarnings("error", category=cuda.core.CUDAWarning)`` to make - such failures loud. The new :doc:`error handling <../error_handling>` page - documents what an exception from ``cuda.core`` guarantees, how failures that - cannot be raised are reported, and how context restoration failures and - sticky CUDA errors are handled. - -- :class:`CUDAError` and :class:`NVRTCError` are now importable from the - top-level ``cuda.core`` namespace, alongside :class:`CUDAWarning`. Previously - they were only available from a private module. - -- Added :class:`VirtualMemoryBuffer`, the :class:`Buffer` subclass that - :class:`VirtualMemoryResource` returns. Closing it on a capturing stream - other than a default stream raises, because virtual memory deallocation - cannot be captured. A release ordered on a default stream that would - disturb a capture in its context is reported as a :class:`CUDAWarning` - and proceeds without the synchronization. - -Fixes and enhancements ----------------------- - -- :attr:`system.Device.uuid_without_prefix` preserves UUIDs that NVML already - reports without a prefix, such as on Orin. This also allows - :meth:`system.Device.to_cuda_device` to match those devices correctly. - (`#2947 `__) - -- A :class:`Program` that is collected as part of a reference cycle no longer - raises ``AttributeError`` from its destructor, and the source file it wrote - for a ``debug`` or ``lineinfo`` build is removed. The cyclic collector - clears object attributes before the destructor runs, and the destructor - read the program options to locate that file. - (`#2876 `__) - -- ``Graph.__getitem__`` now declares an overload for each node type that has - an executable view, so type checkers and editors see the precise view type: - indexing with a :class:`~graph.KernelNode` yields an - :class:`~graph.ExecutableKernelNode`, and so on for memset, memcpy, - child-graph, event-record, event-wait, and host-callback nodes. Indexing - with a node type that has no executable view is now also reported - statically. - (`#2482 `__) - -- :class:`~_memory.DeviceMemoryResource` can now be constructed for a - device's default pool (without options), and :attr:`Device.memory_resource` - first accessed, while the calling thread is inside a ``global`` or - ``thread_local`` stream capture. The constructor applies its - release-threshold policy with pool attribute calls that the driver refuses - during such a capture; it now makes them in relaxed capture mode and - restores the thread's previous mode, so the capture stays valid. Creating a - new pool with options during such a capture is still refused by the driver. - (`#2834 `__) - -- Ending, closing, or collecting a top-level :class:`~graph.GraphBuilder` - whose capture was invalidated, or left unjoined by a - :meth:`~graph.GraphBuilder.join` that failed partway, no longer crashes. - :meth:`~graph.GraphBuilder.end_building` and - :meth:`~graph.GraphBuilder.close` end the capture and raise the driver - error, after which the builder holds no graph. A failed ``join`` also closes - the builders it did not join, even when closing one of them fails. This - does not extend to the body - builder of a conditional node: an invalidated body capture leaves the parent - graph invalid as well, and ending it may crash the process - (`#2918 `__). Calling - ``end_building`` on a forked builder is now rejected with ``RuntimeError``; - forked builders are ended by ``join``. - (`#2834 `__, - `#2776 `__) - -- :class:`Device` methods that create resources or synchronize now act on that - device's bound context, even when another device is current. They do not - change which device is current. For example, ``dev1.sync()`` synchronizes - device 1's bound context even when device 0 is current, and no longer - touches other contexts on device 1 (such as a green context). :meth:`Device.set_current` - now always returns a :class:`Context` with the correct device ID; passing a - context created on a different device than the receiver now delegates to - that device's own :meth:`~Device.set_current` instead of raising, so a - context this method returns can always be pushed back through any - :class:`Device` object. - (`#2311 `__) - -- :meth:`Stream.wait` given a stream now works when that stream belongs to a - device that is not current. The temporary ordering event is created in the - waited-on stream's context rather than the current one, which - ``cuEventRecord`` rejects when the two differ. The stream ordering applied - when importing foreign arrays and tensors uses the producer stream's context - the same way. - (`#2311 `__) - -- :attr:`LegacyPinnedMemoryResource.device_id` now returns ``-1``, as - documented for memory that is not bound to a device and as - :class:`PinnedMemoryResource` already does, instead of raising - ``RuntimeError``. :attr:`Buffer.device_id` on such a buffer returns ``-1`` - as well, which also lets a pinned buffer back a linear or pitched texture - resource. - -- Cleanup failures are now reported as :class:`CUDAWarning` instead of being - written to ``stderr`` with ``print`` or ``fprintf``, so they can be filtered, - captured with :func:`warnings.catch_warnings`, and escalated. Failures of - ``cuStreamDestroy``, ``cuEventDestroy``, ``cuMemFree``, ``cuMemFreeAsync``, - ``cuMemFreeHost``, ``cuMemPoolDestroy``, ``cuGreenCtxDestroy``, - ``cuGraphDestroy``, ``cuGraphExecDestroy``, ``cuGraphicsUnregisterResource``, - ``cuLinkDestroy``, ``cuArrayDestroy``, ``cuMipmappedArrayDestroy``, - ``cuTexObjectDestroy``, ``cuSurfObjectDestroy``, user-object releases and the - NVRTC, NVVM and nvJitLink destroy calls made from destructors were previously - discarded; they are now reported, and each message names the failed call, - the handle of the affected resource and the CUDA error, so Python's - once-per-call-site warning filter does not collapse independent failures. - ``CUDA_ERROR_DEINITIALIZED`` (the driver is shutting down) is not reported. Test code that matched the old ``stderr`` - text uses ``pytest.warns(CUDAWarning)`` instead. - -- When a :class:`Device` method has to run in the device's context and the - caller's context cannot be restored afterwards, the created resource is - destroyed and the raised ``CUDAError`` now carries a note (Python 3.11+; - appended to the message on 3.10) stating that the caller's context could not - be restored and which context is current. Previously the error named only - the driver status of the failed ``cuCtxSetCurrent`` call. If the call itself - failed as well, its error is raised and the restoration failure is the note. - A restoration failure during resource cleanup is reported as - :class:`CUDAWarning`. - -- Updating a memcpy or memset graph node whose context differs from the current - one no longer risks a dangling node parameter when the caller's context - cannot be restored after the update: the resources referenced by the new - parameters are now retained before the restoration failure is raised. - -- :meth:`Device.set_current` with an explicit :class:`Context` now switches - contexts with a single driver call, so a failure leaves the previous context - current instead of leaving the thread with no context. It also works when no - context is current, returning ``None``. - -- A failed rollback of a partially embedded child graph node is now attached - as a note to the exception that triggered the rollback (reported as - :class:`CUDAWarning` on Python 3.10), and a failed ``cuStreamEndCapture`` - made when a still-building :class:`~graph.GraphBuilder` is garbage collected - is now reported as :class:`CUDAWarning`; both were silent. - -- :attr:`Stream.device` and related queries on a stream whose context is not - current now restore the caller's context even when the device query fails. - -- Closing a :class:`DeviceMemoryResource` now revokes the peer access it - granted before the driver destroys the pool. The revocation used to be one - request that named every device, including the pool's own; the driver - rejects such a request as a whole, so it never took effect and a pool - created later could inherit that access through a recycled handle. Each - device is now revoked with its own request, so no single device can block - the others. - (`#2807 `__) - -- Calling ``discard()`` on the set-like :attr:`graph.GraphNode.pred` and - :attr:`graph.GraphNode.succ` views with an object that is not a - :class:`~graph.GraphNode` is now a no-op instead of raising ``TypeError``, - consistent with :class:`collections.abc.MutableSet` semantics. - -- :class:`VirtualMemoryResource` now holds each physical allocation, address - reservation and mapping in a reference-counted handle, so everything a - buffer maps is released when the last buffer that maps it closes. The - physical memory was leaked on every ``allocate()`` and grow - (`#2344 `__), the address - range added by an in-place grow could not be freed - (`#2887 `__), a second - grow of a buffer failed - (`#2908 `__), an - unaligned grow recorded a size the driver rejects at close - (`#2907 `__), and a - buffer was unmapped without waiting for the work queued on its stream - (`#2886 `__). No - reservation is freed by hand any more - (`#2345 `__, - `#2388 `__). The - behavior of the resource changes as follows. - :meth:`VirtualMemoryResource.modify_allocation` returns a new - :class:`VirtualMemoryBuffer` and leaves the buffer passed in open; the two - alias the same physical memory, which is freed when the last of them closes, - and the pointer is preserved when the driver grants the adjacent address - range. The result is always a new buffer, so closing it never closes the - buffer passed in; a request the buffer already covers returns a full alias - without a driver call, and - :attr:`Buffer.size` after a grow is the aligned total. The ``config`` - argument applies to the chunk that call adds and is no longer stored on the - resource (`#2909 `__); - it must keep the resource's location and passes the constructor's option - checks. It never changes the access of memory that is already mapped, so a - request the buffer already covers has no effect even with ``config``. - Buffers from ``allocate()`` free themselves when they close and no longer - call :meth:`VirtualMemoryResource.deallocate`, which now serves pointers - wrapped with :meth:`Buffer.from_handle`. A buffer records the stream passed - to ``allocate()``, and closing it waits for the work queued on that stream - before it unmaps, because ``cuMemUnmap`` is not stream-ordered; close the - buffer explicitly, or record an idle deallocation stream, to control when - that wait happens. ``modify_allocation`` accepts only buffers - this resource returned. ``location_type="host"`` requires - ``handle_type=None``, which the driver requires. ``allocate(0)`` returns an - empty buffer without a driver call. - -- The ``StridedMemoryView`` DLPack C exchange API now preserves the underlying - Python exception when an export or import fails, as required by the DLPack - contract, instead of returning ``-1`` with no exception set. diff --git a/cuda_core/releasenotes/bindings-version-floor-b17a649521e708d0.yaml b/cuda_core/releasenotes/bindings-version-floor-b17a649521e708d0.yaml new file mode 100644 index 00000000000..c84b8d7013c --- /dev/null +++ b/cuda_core/releasenotes/bindings-version-floor-b17a649521e708d0.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + ``cuda.core`` now requires a minimum ``cuda-bindings`` version per CUDA major, at build and run + time: 12.9.8 for CUDA 12 and 13.4.1 for CUDA 13. See the + :ref:`support policy `. ``import cuda.core`` with an older + ``cuda-bindings`` fails with a message that names the required version and how to install it. + ``pip install cuda-core[cu12]`` or ``pip install cuda-core[cu13]`` installs a compatible + version. Supported CUDA drivers and CUDA Toolkit libraries are unchanged. Previously + ``cuda.core`` accepted any ``cuda-bindings`` of the right major, and an older one produced + import errors for missing C functions, silently disabled features, or crashes. + (https://github.com/NVIDIA/cuda-python/issues/2783) (#2920) diff --git a/cuda_core/releasenotes/cleanup-failures-as-warnings-75c52b61a448ad64.yaml b/cuda_core/releasenotes/cleanup-failures-as-warnings-75c52b61a448ad64.yaml new file mode 100644 index 00000000000..61d3db28527 --- /dev/null +++ b/cuda_core/releasenotes/cleanup-failures-as-warnings-75c52b61a448ad64.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + Cleanup failures are now reported as :class:`CUDAWarning` instead of being written to ``stderr`` + with ``print`` or ``fprintf``, so they can be filtered, captured with + :func:`warnings.catch_warnings`, and escalated. Failures of ``cuStreamDestroy``, + ``cuEventDestroy``, ``cuMemFree``, ``cuMemFreeAsync``, ``cuMemFreeHost``, ``cuMemPoolDestroy``, + ``cuGreenCtxDestroy``, ``cuGraphDestroy``, ``cuGraphExecDestroy``, + ``cuGraphicsUnregisterResource``, ``cuLinkDestroy``, ``cuArrayDestroy``, + ``cuMipmappedArrayDestroy``, ``cuTexObjectDestroy``, ``cuSurfObjectDestroy``, user-object + releases and the NVRTC, NVVM and nvJitLink destroy calls made from destructors were previously + discarded; they are now reported, and each message names the failed call, the handle of the + affected resource and the CUDA error, so Python's once-per-call-site warning filter does not + collapse independent failures. ``CUDA_ERROR_DEINITIALIZED`` (the driver is shutting down) is not + reported. Test code that matched the old ``stderr`` text uses ``pytest.warns(CUDAWarning)`` + instead. (#2759) diff --git a/cuda_core/releasenotes/context-restore-failure-note-3bdf84a0cb1ef5bd.yaml b/cuda_core/releasenotes/context-restore-failure-note-3bdf84a0cb1ef5bd.yaml new file mode 100644 index 00000000000..eea7f966c80 --- /dev/null +++ b/cuda_core/releasenotes/context-restore-failure-note-3bdf84a0cb1ef5bd.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + When a :class:`Device` method has to run in the device's context and the + caller's context cannot be restored afterwards, the created resource is + destroyed and the raised ``CUDAError`` now carries a note (Python 3.11+; + appended to the message on 3.10) stating that the caller's context could not + be restored and which context is current. Previously the error named only + the driver status of the failed ``cuCtxSetCurrent`` call. If the call itself + failed as well, its error is raised and the restoration failure is the note. + A restoration failure during resource cleanup is reported as + :class:`CUDAWarning`. (#2759) diff --git a/cuda_core/releasenotes/cpp-layer-driver-entry-points-d9ba7302b7570c9b.yaml b/cuda_core/releasenotes/cpp-layer-driver-entry-points-d9ba7302b7570c9b.yaml new file mode 100644 index 00000000000..c0edfeb0fd7 --- /dev/null +++ b/cuda_core/releasenotes/cpp-layer-driver-entry-points-d9ba7302b7570c9b.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + The C++ layer calls the driver through the entry points ``cuda-bindings`` resolves rather than + through its Cython wrappers. As a result, a driver function that the installed driver lacks can + no longer surface as ``SystemError`` from a C++ call. + (https://github.com/NVIDIA/cuda-python/issues/2783) (#2920) diff --git a/cuda_core/releasenotes/cuda-error-top-level-b1aa0da2621e49f6.yaml b/cuda_core/releasenotes/cuda-error-top-level-b1aa0da2621e49f6.yaml new file mode 100644 index 00000000000..d956bf8e177 --- /dev/null +++ b/cuda_core/releasenotes/cuda-error-top-level-b1aa0da2621e49f6.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + :class:`CUDAError` and :class:`NVRTCError` are now importable from the + top-level ``cuda.core`` namespace, alongside :class:`CUDAWarning`. Previously + they were only available from a private module. (#2759) diff --git a/cuda_core/releasenotes/cuda-warning-7623597db0b2e2d6.yaml b/cuda_core/releasenotes/cuda-warning-7623597db0b2e2d6.yaml new file mode 100644 index 00000000000..0ba58dcad2a --- /dev/null +++ b/cuda_core/releasenotes/cuda-warning-7623597db0b2e2d6.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + Added :class:`CUDAWarning`, the warning category ``cuda.core`` uses for CUDA + errors that cannot be raised, such as a failed driver call while a resource + is released by the garbage collector. Filter on it with + ``warnings.filterwarnings("error", category=cuda.core.CUDAWarning)`` to make + such failures loud. The new :doc:`error handling <../error_handling>` page + documents what an exception from ``cuda.core`` guarantees, how failures that + cannot be raised are reported, and how context restoration failures and + sticky CUDA errors are handled. (#2759) diff --git a/cuda_core/releasenotes/device-methods-bound-context-68680878c62dbe17.yaml b/cuda_core/releasenotes/device-methods-bound-context-68680878c62dbe17.yaml new file mode 100644 index 00000000000..5816f796ae1 --- /dev/null +++ b/cuda_core/releasenotes/device-methods-bound-context-68680878c62dbe17.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :class:`Device` methods that create resources or synchronize now act on that + device's bound context, even when another device is current. They do not + change which device is current. For example, ``dev1.sync()`` synchronizes + device 1's bound context even when device 0 is current, and no longer + touches other contexts on device 1 (such as a green context). :meth:`Device.set_current` + now always returns a :class:`Context` with the correct device ID; passing a + context created on a different device than the receiver now delegates to + that device's own :meth:`~Device.set_current` instead of raising, so a + context this method returns can always be pushed back through any + :class:`Device` object. + (`#2311 `__) (#2750) diff --git a/cuda_core/releasenotes/dlpack-preserve-exception-96ac0894ec880fda.yaml b/cuda_core/releasenotes/dlpack-preserve-exception-96ac0894ec880fda.yaml new file mode 100644 index 00000000000..d4eaa5776d1 --- /dev/null +++ b/cuda_core/releasenotes/dlpack-preserve-exception-96ac0894ec880fda.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + The ``StridedMemoryView`` DLPack C exchange API now preserves the underlying + Python exception when an export or import fails, as required by the DLPack + contract, instead of returning ``-1`` with no exception set. (#2939) diff --git a/cuda_core/releasenotes/dmr-close-revokes-peer-access-c6fdad418c6a673b.yaml b/cuda_core/releasenotes/dmr-close-revokes-peer-access-c6fdad418c6a673b.yaml new file mode 100644 index 00000000000..2a1bf5a4fe8 --- /dev/null +++ b/cuda_core/releasenotes/dmr-close-revokes-peer-access-c6fdad418c6a673b.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + Closing a :class:`DeviceMemoryResource` now revokes the peer access it + granted before the driver destroys the pool. The revocation used to be one + request that named every device, including the pool's own; the driver + rejects such a request as a whole, so it never took effect and a pool + created later could inherit that access through a recycled handle. Each + device is now revoked with its own request, so no single device can block + the others. + (`#2807 `__) (#2871) diff --git a/cuda_core/releasenotes/dmr-default-pool-in-capture-51f3baf092d23a79.yaml b/cuda_core/releasenotes/dmr-default-pool-in-capture-51f3baf092d23a79.yaml new file mode 100644 index 00000000000..ea22b3667b0 --- /dev/null +++ b/cuda_core/releasenotes/dmr-default-pool-in-capture-51f3baf092d23a79.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :class:`~_memory.DeviceMemoryResource` can now be constructed for a + device's default pool (without options), and :attr:`Device.memory_resource` + first accessed, while the calling thread is inside a ``global`` or + ``thread_local`` stream capture. The constructor applies its + release-threshold policy with pool attribute calls that the driver refuses + during such a capture; it now makes them in relaxed capture mode and + restores the thread's previous mode, so the capture stays valid. Creating a + new pool with options during such a capture is still refused by the driver. + (`#2834 `__) (#2838) diff --git a/cuda_core/releasenotes/feature-availability-from-driver-5c84637adb9aca36.yaml b/cuda_core/releasenotes/feature-availability-from-driver-5c84637adb9aca36.yaml new file mode 100644 index 00000000000..dd6c7d2c41b --- /dev/null +++ b/cuda_core/releasenotes/feature-availability-from-driver-5c84637adb9aca36.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + With the ``cuda-bindings`` floor in place, whether a feature is available now depends only on + the CUDA driver and the ``cuda.core`` build's CUDA major. The checks that also inspected the + ``cuda-bindings`` version are gone, and so are their error messages. Error messages that name a + minimum now name a driver version. ``cuda.core.system`` always uses NVML through + ``cuda-bindings``, so ``cuda.core.system.CUDA_BINDINGS_NVML_IS_COMPATIBLE`` is always ``True``. + :mod:`cuda.core.checkpoint` requires the CUDA 13 build of ``cuda.core`` and now says so. That + requirement was already in effect, because the CUDA 12 ``cuda-bindings`` lack a type it uses. + (https://github.com/NVIDIA/cuda-python/issues/2783) (#2920) diff --git a/cuda_core/releasenotes/graph-builder-invalidated-capture-053511cf302cb705.yaml b/cuda_core/releasenotes/graph-builder-invalidated-capture-053511cf302cb705.yaml new file mode 100644 index 00000000000..d173637de09 --- /dev/null +++ b/cuda_core/releasenotes/graph-builder-invalidated-capture-053511cf302cb705.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + Ending, closing, or collecting a top-level :class:`~graph.GraphBuilder` + whose capture was invalidated, or left unjoined by a + :meth:`~graph.GraphBuilder.join` that failed partway, no longer crashes. + :meth:`~graph.GraphBuilder.end_building` and + :meth:`~graph.GraphBuilder.close` end the capture and raise the driver + error, after which the builder holds no graph. A failed ``join`` also closes + the builders it did not join, even when closing one of them fails. This + does not extend to the body + builder of a conditional node: an invalidated body capture leaves the parent + graph invalid as well, and ending it may crash the process + (`#2918 `__). Calling + ``end_building`` on a forked builder is now rejected with ``RuntimeError``; + forked builders are ended by ``join``. + (`#2834 `__, + `#2776 `__) (#2838) diff --git a/cuda_core/releasenotes/graph-getitem-overloads-d39c63769136be19.yaml b/cuda_core/releasenotes/graph-getitem-overloads-d39c63769136be19.yaml new file mode 100644 index 00000000000..91f2ca88c8e --- /dev/null +++ b/cuda_core/releasenotes/graph-getitem-overloads-d39c63769136be19.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + ``Graph.__getitem__`` now declares an overload for each node type that has + an executable view, so type checkers and editors see the precise view type: + indexing with a :class:`~graph.KernelNode` yields an + :class:`~graph.ExecutableKernelNode`, and so on for memset, memcpy, + child-graph, event-record, event-wait, and host-callback nodes. Indexing + with a node type that has no executable view is now also reported + statically. + (`#2482 `__) (#2869) diff --git a/cuda_core/releasenotes/graph-node-discard-noop-2b6c4d6601993078.yaml b/cuda_core/releasenotes/graph-node-discard-noop-2b6c4d6601993078.yaml new file mode 100644 index 00000000000..c8e44d6c37e --- /dev/null +++ b/cuda_core/releasenotes/graph-node-discard-noop-2b6c4d6601993078.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + Calling ``discard()`` on the set-like :attr:`graph.GraphNode.pred` and + :attr:`graph.GraphNode.succ` views with an object that is not a + :class:`~graph.GraphNode` is now a no-op instead of raising ``TypeError``, + consistent with :class:`collections.abc.MutableSet` semantics. (#2893) diff --git a/cuda_core/releasenotes/graph-node-update-context-restore-15c7910882ae0a1b.yaml b/cuda_core/releasenotes/graph-node-update-context-restore-15c7910882ae0a1b.yaml new file mode 100644 index 00000000000..1db40f08ad6 --- /dev/null +++ b/cuda_core/releasenotes/graph-node-update-context-restore-15c7910882ae0a1b.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + Updating a memcpy or memset graph node whose context differs from the current + one no longer risks a dangling node parameter when the caller's context + cannot be restored after the update: the resources referenced by the new + parameters are now retained before the restoration failure is raised. (#2759) diff --git a/cuda_core/releasenotes/graph-rollback-failure-note-40598aa74bf43efc.yaml b/cuda_core/releasenotes/graph-rollback-failure-note-40598aa74bf43efc.yaml new file mode 100644 index 00000000000..019a496d3eb --- /dev/null +++ b/cuda_core/releasenotes/graph-rollback-failure-note-40598aa74bf43efc.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + A failed rollback of a partially embedded child graph node is now attached + as a note to the exception that triggered the rollback (reported as + :class:`CUDAWarning` on Python 3.10), and a failed ``cuStreamEndCapture`` + made when a still-building :class:`~graph.GraphBuilder` is garbage collected + is now reported as :class:`CUDAWarning`; both were silent. (#2759) diff --git a/cuda_core/releasenotes/legacy-pinned-device-id-0cdbd4699d5d3806.yaml b/cuda_core/releasenotes/legacy-pinned-device-id-0cdbd4699d5d3806.yaml new file mode 100644 index 00000000000..7ba81a33f45 --- /dev/null +++ b/cuda_core/releasenotes/legacy-pinned-device-id-0cdbd4699d5d3806.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :attr:`LegacyPinnedMemoryResource.device_id` now returns ``-1``, as + documented for memory that is not bound to a device and as + :class:`PinnedMemoryResource` already does, instead of raising + ``RuntimeError``. :attr:`Buffer.device_id` on such a buffer returns ``-1`` + as well, which also lets a pinned buffer back a linear or pitched texture + resource. (#2750) diff --git a/cuda_core/releasenotes/linker-incremental-5d1fe2b0a24cee39.yaml b/cuda_core/releasenotes/linker-incremental-5d1fe2b0a24cee39.yaml new file mode 100644 index 00000000000..4e8480f7420 --- /dev/null +++ b/cuda_core/releasenotes/linker-incremental-5d1fe2b0a24cee39.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + Added incremental linking to :class:`Linker` through + ``LinkerOptions(incremental=True)`` on nvJitLink 13.2 or newer. A partial + native result is returned as a CUBIN and can be passed directly to another + linker. :meth:`Linker.link` also accepts ``"ltoir"`` on nvJitLink 13.3 or + newer so incremental LTO chains can retain IR until their final link. Direct + PTX and CUBIN inputs are rejected for this target because they carry no LTOIR + and nvJitLink would otherwise silently omit them. + (`#2369 `__) (#2867) diff --git a/cuda_core/releasenotes/managed-buffer-last-prefetch-location-96cc2bedbdaf0c87.yaml b/cuda_core/releasenotes/managed-buffer-last-prefetch-location-96cc2bedbdaf0c87.yaml new file mode 100644 index 00000000000..b942515efe6 --- /dev/null +++ b/cuda_core/releasenotes/managed-buffer-last-prefetch-location-96cc2bedbdaf0c87.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + Added the read-only :attr:`ManagedBuffer.last_prefetch_location` property, + which reports the destination requested by the most recent explicit + prefetch across the buffer. It returns a :class:`Device` or :class:`Host`, + including NUMA-specific host locations on CUDA 13, and ``None`` if any page + has never been prefetched or the pages do not share one last-prefetch + location. This reports the requested target, not current residency or + completion of the asynchronous prefetch operation. + (`#2109 `__) (#2774) diff --git a/cuda_core/releasenotes/orin-uuid-without-prefix-c11eb4c949ed67f2.yaml b/cuda_core/releasenotes/orin-uuid-without-prefix-c11eb4c949ed67f2.yaml new file mode 100644 index 00000000000..7720c43bb5a --- /dev/null +++ b/cuda_core/releasenotes/orin-uuid-without-prefix-c11eb4c949ed67f2.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :attr:`system.Device.uuid_without_prefix` preserves UUIDs that NVML already + reports without a prefix, such as on Orin. This also allows + :meth:`system.Device.to_cuda_device` to match those devices correctly. (#2947) diff --git a/cuda_core/releasenotes/program-cycle-destructor-d111b62063cd1513.yaml b/cuda_core/releasenotes/program-cycle-destructor-d111b62063cd1513.yaml new file mode 100644 index 00000000000..eb986f5fcdf --- /dev/null +++ b/cuda_core/releasenotes/program-cycle-destructor-d111b62063cd1513.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + A :class:`Program` that is collected as part of a reference cycle no longer + raises ``AttributeError`` from its destructor, and the source file it wrote + for a ``debug`` or ``lineinfo`` build is removed. The cyclic collector + clears object attributes before the destructor runs, and the destructor + read the program options to locate that file. + (`#2876 `__) (#2879) diff --git a/cuda_core/releasenotes/set-current-single-call-a2fb40af8e2d1dee.yaml b/cuda_core/releasenotes/set-current-single-call-a2fb40af8e2d1dee.yaml new file mode 100644 index 00000000000..f511d6683ed --- /dev/null +++ b/cuda_core/releasenotes/set-current-single-call-a2fb40af8e2d1dee.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :meth:`Device.set_current` with an explicit :class:`Context` now switches + contexts with a single driver call, so a failure leaves the previous context + current instead of leaving the thread with no context. It also works when no + context is current, returning ``None``. (#2759) diff --git a/cuda_core/releasenotes/source-build-header-match-0704cac6c0c72756.yaml b/cuda_core/releasenotes/source-build-header-match-0704cac6c0c72756.yaml new file mode 100644 index 00000000000..b1ee888c54e --- /dev/null +++ b/cuda_core/releasenotes/source-build-header-match-0704cac6c0c72756.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +upgrade: + - | + A source build of ``cuda.core`` must use a ``cuda.h`` of the same major.minor as its + ``cuda-bindings``. If they differ, the build fails early. + (https://github.com/NVIDIA/cuda-python/issues/2783) (#2920) diff --git a/cuda_core/releasenotes/stream-device-context-restore-d08f1046789930a3.yaml b/cuda_core/releasenotes/stream-device-context-restore-d08f1046789930a3.yaml new file mode 100644 index 00000000000..cba4d3d80e9 --- /dev/null +++ b/cuda_core/releasenotes/stream-device-context-restore-d08f1046789930a3.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :attr:`Stream.device` and related queries on a stream whose context is not + current now restore the caller's context even when the device query fails. (#2759) diff --git a/cuda_core/releasenotes/stream-wait-other-device-ae307899cad418d7.yaml b/cuda_core/releasenotes/stream-wait-other-device-ae307899cad418d7.yaml new file mode 100644 index 00000000000..afc1b671875 --- /dev/null +++ b/cuda_core/releasenotes/stream-wait-other-device-ae307899cad418d7.yaml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :meth:`Stream.wait` given a stream now works when that stream belongs to a + device that is not current. The temporary ordering event is created in the + waited-on stream's context rather than the current one, which + ``cuEventRecord`` rejects when the two differ. The stream ordering applied + when importing foreign arrays and tensors uses the producer stream's context + the same way. + (`#2311 `__) (#2750) diff --git a/cuda_core/releasenotes/virtualmemorybuffer-class-5baa60b7446e63aa.yaml b/cuda_core/releasenotes/virtualmemorybuffer-class-5baa60b7446e63aa.yaml new file mode 100644 index 00000000000..fe7a93fdd14 --- /dev/null +++ b/cuda_core/releasenotes/virtualmemorybuffer-class-5baa60b7446e63aa.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +features: + - | + Added :class:`VirtualMemoryBuffer`, the :class:`Buffer` subclass that + :class:`VirtualMemoryResource` returns. Closing it on a capturing stream + other than a default stream raises, because virtual memory deallocation + cannot be captured. A release ordered on a default stream that would + disturb a capture in its context is reported as a :class:`CUDAWarning` + and proceeds without the synchronization. (#2917) diff --git a/cuda_core/releasenotes/virtualmemoryresource-handle-layer-a4130dca601d51f6.yaml b/cuda_core/releasenotes/virtualmemoryresource-handle-layer-a4130dca601d51f6.yaml new file mode 100644 index 00000000000..ff9566ee920 --- /dev/null +++ b/cuda_core/releasenotes/virtualmemoryresource-handle-layer-a4130dca601d51f6.yaml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +fixes: + - | + :class:`VirtualMemoryResource` now holds each physical allocation, address + reservation and mapping in a reference-counted handle, so everything a + buffer maps is released when the last buffer that maps it closes. The + physical memory was leaked on every ``allocate()`` and grow + (`#2344 `__), the address + range added by an in-place grow could not be freed + (`#2887 `__), a second + grow of a buffer failed + (`#2908 `__), an + unaligned grow recorded a size the driver rejects at close + (`#2907 `__), and a + buffer was unmapped without waiting for the work queued on its stream + (`#2886 `__). No + reservation is freed by hand any more + (`#2345 `__, + `#2388 `__). (#2917) +upgrade: + - | + The behavior of :class:`VirtualMemoryResource` changes as follows. + :meth:`VirtualMemoryResource.modify_allocation` returns a new + :class:`VirtualMemoryBuffer` and leaves the buffer passed in open; the two + alias the same physical memory, which is freed when the last of them closes, + and the pointer is preserved when the driver grants the adjacent address + range. The result is always a new buffer, so closing it never closes the + buffer passed in; a request the buffer already covers returns a full alias + without a driver call, and :attr:`Buffer.size` after a grow is the aligned + total. The ``config`` argument applies to the chunk that call adds and is no + longer stored on the resource + (`#2909 `__); it must + keep the resource's location and passes the constructor's option checks. It + never changes the access of memory that is already mapped, so a request the + buffer already covers has no effect even with ``config``. Buffers from + ``allocate()`` free themselves when they close and no longer call + :meth:`VirtualMemoryResource.deallocate`, which now serves pointers wrapped + with :meth:`Buffer.from_handle`. A buffer records the stream passed to + ``allocate()``, and closing it waits for the work queued on that stream + before it unmaps, because ``cuMemUnmap`` is not stream-ordered; close the + buffer explicitly, or record an idle deallocation stream, to control when + that wait happens. ``modify_allocation`` accepts only buffers this resource + returned. ``location_type="host"`` requires ``handle_type=None``, which the + driver requires. ``allocate(0)`` returns an empty buffer without a driver + call. (#2917) diff --git a/cuda_pathfinder/AGENTS.md b/cuda_pathfinder/AGENTS.md index 2b6a8e03115..8e03352d3e8 100644 --- a/cuda_pathfinder/AGENTS.md +++ b/cuda_pathfinder/AGENTS.md @@ -69,3 +69,16 @@ Windows. - Run package tests: `pytest tests/` - Build package docs: `cd docs && ./build_docs.sh` + +## Release notes + +- Notes for 1.9.0 and later are per-change files in `releasenotes/`; do not add + hand-written pages to `docs/source/release/` for new releases. +- Changes to `cuda/` or `pyproject.toml` need a note, unless the PR has the + `skip-release-note` label. Create one with + `python toolshed/add_note.py cuda-pathfinder `. The old "Highlights" section is `prelude`. +- Entries use `cuda.pathfinder` as the current module for Sphinx roles. +- Do not add PR links to notes (they are added automatically) and do not + rename merged notes. `issues` (known issues) entries are repeated on every + later release until deleted. See the "Release notes" section of the top-level + `CONTRIBUTING.md`. diff --git a/cuda_pathfinder/docs/source/conf.py b/cuda_pathfinder/docs/source/conf.py index aa6cd964b54..66c33e2b36a 100644 --- a/cuda_pathfinder/docs/source/conf.py +++ b/cuda_pathfinder/docs/source/conf.py @@ -15,6 +15,7 @@ sys.path.insert(0, str((Path(__file__).parents[3] / "cuda_python" / "docs" / "exts").absolute())) +from release_ranges import PACKAGES, format_version # -- Project information ----------------------------------------------------- @@ -50,8 +51,18 @@ def _html_baseurl(): "sphinx_copybutton", "release_toc", "release_date", + "release_notes", ] +# Release notes from 1.9.0 on are built from the notes in +# cuda_pathfinder/releasenotes; earlier ones are hand-written pages in release/. +release_notes_dir = PACKAGES["cuda-pathfinder"].notes_dir +release_notes_tag_prefix = PACKAGES["cuda-pathfinder"].tag_prefix +release_notes_first_version = format_version(PACKAGES["cuda-pathfinder"].first_version) +release_notes_title = "``cuda-pathfinder`` {version} Release notes" +release_notes_unreleased_title = "``cuda-pathfinder`` unreleased changes" +release_notes_prolog = ".. currentmodule:: cuda.pathfinder" + nb_execution_mode = "off" numfig = True diff --git a/cuda_pathfinder/docs/source/release.rst b/cuda_pathfinder/docs/source/release.rst index 601f3bf220e..b8726b4d661 100644 --- a/cuda_pathfinder/docs/source/release.rst +++ b/cuda_pathfinder/docs/source/release.rst @@ -1,4 +1,4 @@ -.. SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +.. SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. .. SPDX-License-Identifier: Apache-2.0 Release Notes @@ -8,4 +8,4 @@ Release Notes :maxdepth: 3 :glob: - release/*[0-9]-notes + release/*-notes diff --git a/cuda_pathfinder/docs/source/release/.gitignore b/cuda_pathfinder/docs/source/release/.gitignore new file mode 100644 index 00000000000..eedc98545c2 --- /dev/null +++ b/cuda_pathfinder/docs/source/release/.gitignore @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Pages generated by cuda_python/docs/exts/release_notes.py, which covers +# releases from the first reno version on (see release_ranges.PACKAGES). Releases +# before it keep their hand-written pages here. +/unreleased-notes.rst +/1.9*-notes.rst +/1.[1-9][0-9]*-notes.rst +/[2-9]*-notes.rst +/[1-9][0-9]*-notes.rst diff --git a/cuda_python/AGENTS.md b/cuda_python/AGENTS.md index 9eef3d4b772..c297583616a 100644 --- a/cuda_python/AGENTS.md +++ b/cuda_python/AGENTS.md @@ -32,3 +32,18 @@ monorepo. can make the install unresolvable after a `cuda-core` release. - If you update docs structure, ensure `docs/build_all_docs.sh` still collects docs from `cuda_python`, `cuda_bindings`, `cuda_core`, and `cuda_pathfinder`. + +## Release notes + +- Notes for 13.5.0 and later are per-change files in `releasenotes/`; do not add + hand-written pages to `docs/source/release/` for new releases. +- Changes to `pyproject.toml` or `setup.py` (for example a dependency pin bump + for a new `cuda-core` or `cuda-bindings` release) need a note, unless the PR + has the `skip-release-note` label. Create one with + `python toolshed/add_note.py cuda-python `. +- `cuda-python` shares the `v` tag namespace with `cuda-bindings`; each package + scans only its own notes, so a release of both needs a note in each. +- Do not add PR links to notes (they are added automatically) and do not + rename merged notes. `issues` (known issues) entries are repeated on every + later release until deleted. See the "Release notes" section of the top-level + `CONTRIBUTING.md`. diff --git a/cuda_python/docs/exts/release_date.py b/cuda_python/docs/exts/release_date.py index 40e61bbc917..7f548758cd4 100644 --- a/cuda_python/docs/exts/release_date.py +++ b/cuda_python/docs/exts/release_date.py @@ -6,8 +6,8 @@ For every release-notes page (``release/-notes``), this extension looks up the corresponding git tag and injects a ``Released on `` line after the RST title. Pages that already -contain such a line, or whose version has no tag yet, are left -untouched. +start with such a line after the title, or whose version has no tag yet, are +left untouched. """ from __future__ import annotations @@ -63,7 +63,14 @@ def _on_source_read(app: Sphinx, docname: str, source: list[str]) -> None: return text = source[0] - if _RELEASED_ON_RE.search(text): + underline = _UNDERLINE_RE.search(text) + if not underline: + return + + # An existing date line is the first thing after the title; the words may well + # appear elsewhere in the page, in an entry. + after = text[underline.end() :] + if _RELEASED_ON_RE.match(after.lstrip()): return version = m.group(1) @@ -76,15 +83,10 @@ def _on_source_read(app: Sphinx, docname: str, source: list[str]) -> None: if not iso_date: return - underline = _UNDERLINE_RE.search(text) - if not underline: - return - date_line = f"Released on {_format_date(iso_date)}" # Insert after the title underline: skip any blank lines, then place # the date line surrounded by single blank lines before the content. - after = text[underline.end() :] stripped = after.lstrip("\n") source[0] = text[: underline.end()] + f"\n\n{date_line}\n\n" + stripped diff --git a/cuda_python/docs/exts/release_notes.py b/cuda_python/docs/exts/release_notes.py new file mode 100644 index 00000000000..5a9373757ad --- /dev/null +++ b/cuda_python/docs/exts/release_notes.py @@ -0,0 +1,468 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Sphinx extension that generates release-notes pages from per-change note files. + +The design is inspired by reno (https://docs.openstack.org/reno/): each change adds a small YAML +file, and the pages are assembled from the git history. It behaves quite differently, though: +notes are attributed to releases by tag ranges rather than by reno's version labels, known issues +are listed on every release until resolved, and a note's text is always the current text. + +A note file is a YAML mapping from a section key to a list of reStructuredText entries. The +sections, and their order and titles, are ``release_ranges.SECTIONS``; known issues (``issues``) come +last. A ``prelude`` section is the introduction of a page and is not a titled section. + +Releases before ``release_notes_first_version`` keep their hand-written +``release/-notes.rst`` pages. For every release tag at or after it, +this extension generates ``/release/-notes.rst`` from +the notes in ``release_notes_dir``, plus an ``unreleased-notes`` page for notes +that are not yet in any release. The pages are written next to the +hand-written ones, so all release notes share one URL scheme. Generated pages +start with a marker comment; the extension refuses to overwrite a file without +it and only deletes stale files that have it. They are git-ignored. + +Which notes appear on which page +================================ + +Only plain ``X.Y.Z`` tags are releases. Pre-release tags (``rc``, +``a``, ``b``) and other packages' tags are ignored, so notes written during a +pre-release roll into the final release. Branch names are never consulted: +every page is built from the history reachable from its own tag, so a patch +tag made on a maintenance branch works even though it is not an ancestor of +``main``. + +* A **minor** release lists the notes added since the previous minor release. + A line's minor release is its first release: normally ``X.Y.0``, but if that + was only a pre-release, the lowest ``X.Y.Z`` that was released. +* A **patch** release (any other release of a line) lists the notes added + since the previous release of the same ``X.Y`` line. +* The **"In development"** page lists the notes added since the newest release + that is in the history of the commit being built. A release tagged on another + branch is not, but it still gets its own page, built from its own tag. + +Because each range is computed independently, a fix that is backported to a +maintenance branch appears on that branch's patch page *and* on the page of +the next minor release that contains it. This duplication is intentional. + +Which notes are on which page is decided by the tags. The text of a note is +the text in the checkout being built (the docs are published from the main +branch), so editing a note that was already released changes the page of every +release that lists it the next time the docs are published. A note whose file +is no longer in the checkout (deleted, or only on a maintenance branch) keeps +the text it had at the tag. + +Known issues +============ + +A note is listed on the releases whose range contains it, but known issues +must be listed on every release until they are resolved. So the ``issues`` +section is not taken from the range. Instead, each page lists every ``issues`` +entry in the notes that exist in the tree at that page's tag (the working +``HEAD`` for the unreleased page). The text of an issue is the text it has at that +tag (the checkout for the unreleased page). Resolving an issue means deleting the entry (or its +note file); pages for earlier tags keep listing it, unchanged. + +Pull request links +================== + +Each entry gets a link to the pull request that introduced it. By default the +pull request is taken from the ``(#N)`` suffix of the subject of the commit that +added the note file (the squash-merge convention); notes whose commit has no +such suffix get no link. + +Note that a fix backported by its own pull request on a maintenance branch gets +that backport's number on the patch page, because the note file is added to the +branch by the backport, while the next minor page (built from the main branch, +where the note came from the original pull request) shows the original number. +Use an explicit marker (below) to show the original on both. + +An entry can name its pull requests itself with ``(#1234)`` or ``(#1234, #1235)`` anywhere in +its text. Every such marker is replaced by links and the automatic lookup is skipped for that +entry. A marker inside an inline literal (``code``) or other backquoted text is just text and is +left alone. This is for notes whose file was added by a different pull request than the one that +made the change, for example notes moved in from hand-written pages. + +The build fails on a shallow clone or when no release tags are present, since +that would silently produce incomplete pages. +""" + +from __future__ import annotations + +import functools +import re +import subprocess +from collections.abc import Iterable +from pathlib import Path + +import yaml +from release_ranges import ( + PRELUDE, + SECTIONS, + format_version, + parse_release_tags, + previous_release, + section_key_re, +) +from sphinx.application import Sphinx +from sphinx.errors import ExtensionError +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +UNRELEASED = "unreleased" +GENERATED_MARKER = ".. Generated by release_notes; do not edit or commit.\n\n" + +_PR_SUFFIX_RE = re.compile(r"\(#(\d+)\)\s*$") +# An explicit "(#1234)" or "(#1234, #1235)" anywhere in an entry, except inside inline literals +# (``...``) or other backquoted text (`...`), where it is just text. Spans are matched too, so +# that a marker inside one is skipped over rather than found. +_SPAN_OR_PRS_RE = re.compile(r"``.+?``|`[^`]+`|\(\s*(?P#\d+(?:\s*,\s*#\d+)*)\s*\)", re.DOTALL) + + +# --------------------------------------------------------------------------- +# Pure logic (no git, no Sphinx) +# --------------------------------------------------------------------------- + + +def _indent_entry(text: str) -> str: + lines = text.strip("\n").split("\n") + out = [f"- {lines[0]}"] + for line in lines[1:]: + if line: + out.append(f" {line}") + else: + out.append("") + return "\n".join(out) + + +def _pr_link(number: str, pr_url: str) -> str: + return f"`PR #{number} <{pr_url.format(number=number)}>`__" + + +def _has_explicit_prs(text: str) -> bool: + return any(m.group("numbers") is not None for m in _SPAN_OR_PRS_RE.finditer(text)) + + +def _render_entry(entry: str, note_pr: str | None, pr_url: str) -> str: + """Link *entry* to its pull request(s). + + Explicit ``(#N, ...)`` markers anywhere in the entry are replaced by links. + Otherwise a link to *note_pr* is appended to the first paragraph. + """ + text = entry.strip("\n") + if _has_explicit_prs(text): + + def replace(m): + if m.group("numbers") is None: + return m.group(0) + return "(" + ", ".join(_pr_link(number, pr_url) for number in re.findall(r"\d+", m.group("numbers"))) + ")" + + return _SPAN_OR_PRS_RE.sub(replace, text) + if note_pr is None: + return text + first, sep, rest = text.partition("\n\n") + return f"{first} ({_pr_link(note_pr, pr_url)}){sep}{rest}" + + +def _entries(data: dict) -> list[str]: + """All the entries of a parsed note, in every section.""" + entries = [] + for value in data.values(): + if isinstance(value, str): + value = [value] + entries.extend(value or []) + return entries + + +def _needs_pr_lookup(data: dict) -> bool: + """Whether some entry of the note has no explicit pull request.""" + return any(not _has_explicit_prs(entry) for entry in _entries(data)) + + +def render_page( + title: str, + sections: Iterable[tuple[str, str]], + notes: list[dict], + issues: list[dict], + pr_url: str, + prolog: str = "", +) -> str: + """Render a release-notes page. + + *sections* is the ordered ``(key, heading)`` list. *notes* are the parsed + notes for the page's range. *issues* are the notes that supply the known + issues. Each is a dict with the parsed note under ``"data"`` and its + pull-request number (or None) under ``"pr"``. + """ + lines = [] + if prolog: + lines.extend([prolog, ""]) + lines.extend([title, "=" * len(title), ""]) + + def entries_for(key, source): + entries = [] + for note in source: + value = note["data"].get(key) or [] + if isinstance(value, str): + value = [value] + entries.extend(_render_entry(entry, note["pr"], pr_url) for entry in value) + return entries + + prelude = entries_for(PRELUDE, notes) + if prelude: + lines.extend(["\n\n".join(e.strip("\n") for e in prelude), ""]) + + wrote_section = bool(prelude) + for key, heading in sections: + if key == "issues": + entries = entries_for(key, issues) + else: + entries = entries_for(key, notes) + if not entries: + continue + wrote_section = True + lines.extend([heading, "-" * len(heading), ""]) + for entry in entries: + lines.extend([_indent_entry(entry), ""]) + + if not wrote_section: + lines.extend(["There are no release notes for this release.", ""]) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# git glue +# --------------------------------------------------------------------------- + + +def _git(repo_root: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( # noqa: S603 + ["git", *args], # noqa: S607 + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if check and result.returncode != 0: + raise ExtensionError(f"release_notes: `git {' '.join(args)}` failed: {result.stderr.strip()}") + return result.stdout + + +def _is_ancestor(repo_root: Path, ancestor: str, descendant: str) -> bool: + result = subprocess.run( # noqa: S603 + ["git", "merge-base", "--is-ancestor", ancestor, descendant], # noqa: S607 + cwd=repo_root, + check=False, + ) + return result.returncode == 0 + + +def _check_clone(repo_root: Path, release_tags: dict) -> None: + if _git(repo_root, "rev-parse", "--is-shallow-repository").strip() == "true": + raise ExtensionError( + "release_notes: this is a shallow git clone, so release notes cannot be built " + "(they would silently be incomplete). Fetch the full history, e.g. " + "`git fetch --unshallow --tags` or `actions/checkout` with `fetch-depth: 0`." + ) + if not release_tags: + raise ExtensionError( + "release_notes: no release tags were found, so release notes cannot be built. " + "Fetch the tags, e.g. `git fetch --tags`." + ) + + +def _notes_in_range(repo_root: Path, notes_dir: str, tag: str, earliest_tag: str | None) -> list[str]: + """The note files added by the commits in ``earliest_tag..tag`` that still exist at *tag*. + + Without *earliest_tag*, the whole history of *tag*. Only commits reachable from *tag* but not + from *earliest_tag* count, so a patch release tagged on a maintenance branch lists only what + that branch added since the release it was made from. A renamed file counts as a new note + (a delete and an add), in the release that contains the rename. + """ + notes_path = notes_dir + rev = tag + if earliest_tag is not None: + rev = f"{earliest_tag}..{tag}" + added = _git(repo_root, "log", "--no-renames", "--diff-filter=A", "--name-only", "--format=", rev, "--", notes_path) + present = set(_git(repo_root, "ls-tree", "-r", "--name-only", tag, "--", notes_path).splitlines()) + paths = {path for path in added.splitlines() if path in present and path.endswith(".yaml")} + return sorted(paths) + + +_warned_paths: set[str] = set() + + +# The git lookups below are cached for one build (_generate_pages clears the caches), because a note +# that is both in a page's range and in its known issues is looked up for the same ref twice. +@functools.cache +def _pr_number(repo_root: Path, ref: str, path: str) -> str | None: + subjects = _git(repo_root, "log", "--diff-filter=A", "--format=%s", ref, "--", path).strip().split("\n") + # The oldest commit that added the file is the last one listed. + m = _PR_SUFFIX_RE.search(subjects[-1]) + if m is not None: + return m.group(1) + if path not in _warned_paths: + _warned_paths.add(path) + # Notes in an unmerged PR have no squash-merge subject yet, which is expected. + log = logger.info + if ref != "HEAD": + log = logger.warning + log("release_notes: no pull request number found for %s", path) + return None + + +def _lookup_pr(repo_root: Path, ref: str, path: str, data: dict) -> str | None: + """The note's pull request from git, unless every entry names its own.""" + if not _needs_pr_lookup(data): + return None + return _pr_number(repo_root, ref, path) + + +@functools.cache +def _note_at_tag(repo_root: Path, ref: str, path: str) -> dict: + """Load a note as it was at *ref*.""" + return yaml.safe_load(_git(repo_root, "show", f"{ref}:{path}")) or {} + + +def _note_data(repo_root: Path, ref: str, path: str) -> dict: + """Load a note: from the checkout if the file is there, else as it was at *ref*.""" + file = repo_root / path + if file.is_file(): + return yaml.safe_load(file.read_text(encoding="utf-8")) or {} + return _note_at_tag(repo_root, ref, path) + + +def _issue_notes(repo_root: Path, ref: str, notes_dir: str) -> list[dict]: + """Return the notes in the tree at *ref* that contain known issues. + + The issues have the text they had at *ref*, so that later edits do not change earlier pages. + The unreleased page (``HEAD``) uses the checkout, like the rest of its notes. + """ + notes_path = notes_dir + out = _git(repo_root, "grep", "-l", "-E", section_key_re("issues"), ref, "--", notes_path, check=False) + notes = [] + for line in sorted(out.splitlines()): + path = line.split(":", 1)[1] + if ref == "HEAD": + data = _note_data(repo_root, ref, path) + else: + data = _note_at_tag(repo_root, ref, path) + notes.append({"data": data, "pr": _lookup_pr(repo_root, ref, path, data)}) + return notes + + +def build_page( + repo_root: Path, + notes_dir: str, + tag: str, + earliest_tag: str | None, + title: str, + pr_url: str, + prolog: str = "", +) -> str | None: + """Build the page for *tag*. + + Passing ``tag="HEAD"`` builds the unreleased page, which returns None when + there are no unreleased notes. + """ + notes = [] + for filename in _notes_in_range(repo_root, notes_dir, tag, earliest_tag): + data = _note_data(repo_root, tag, filename) + notes.append({"data": data, "pr": _lookup_pr(repo_root, tag, filename, data)}) + if tag == "HEAD" and not notes: + return None + issues = _issue_notes(repo_root, tag, notes_dir) + return render_page(title, SECTIONS, notes, issues, pr_url, prolog) + + +# --------------------------------------------------------------------------- +# Sphinx integration +# --------------------------------------------------------------------------- + + +def _write_if_changed(path: Path, content: str) -> None: + content = GENERATED_MARKER + content + if path.exists(): + existing = path.read_text(encoding="utf-8") + if not existing.startswith(GENERATED_MARKER): + raise ExtensionError(f"release_notes: refusing to overwrite hand-written page {path}") + if existing == content: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _generate_pages(app: Sphinx) -> None: + cfg = app.config + if not cfg.release_notes_dir: + return + repo_root = Path(_git(Path(app.srcdir), "rev-parse", "--show-toplevel").strip()) + _pr_number.cache_clear() + _note_at_tag.cache_clear() + out_dir = Path(app.srcdir) / cfg.release_notes_output_dir + written = set() + + tags = _git(repo_root, "tag", "--list").split() + release_tags = parse_release_tags(tags, cfg.release_notes_tag_prefix) + _check_clone(repo_root, release_tags) + + first = tuple(int(part) for part in cfg.release_notes_first_version.split(".")) + notes_releases = sorted(v for v in release_tags if v >= first) + + for version in notes_releases: + earliest = previous_release(version, notes_releases) + earliest_tag = None + if earliest is not None: + earliest_tag = release_tags[earliest] + if not _is_ancestor(repo_root, earliest_tag, release_tags[version]): + raise ExtensionError( + f"release_notes: {earliest_tag} is not an ancestor of {release_tags[version]}, " + "so the notes for the range cannot be determined." + ) + title = cfg.release_notes_title.format(version=format_version(version)) + page = build_page( + repo_root, + cfg.release_notes_dir, + release_tags[version], + earliest_tag, + title, + cfg.release_notes_pr_url, + cfg.release_notes_prolog, + ) + _write_if_changed(out_dir / f"{format_version(version)}-notes.rst", page) + written.add(f"{format_version(version)}-notes.rst") + + # The unreleased page lists what came after the newest release that is in the history of + # HEAD. Releases on other branches are not (a patch tagged on a maintenance branch is not + # in the history of main, and docs built on that branch do not contain a newer minor + # release tagged on main), but their own pages above are built from their own tags. + reachable = [v for v in notes_releases if _is_ancestor(repo_root, release_tags[v], "HEAD")] + latest = None + if reachable: + latest = release_tags[max(reachable)] + title = cfg.release_notes_unreleased_title + page = build_page( + repo_root, cfg.release_notes_dir, "HEAD", latest, title, cfg.release_notes_pr_url, cfg.release_notes_prolog + ) + if page is not None: + _write_if_changed(out_dir / f"{UNRELEASED}-notes.rst", page) + written.add(f"{UNRELEASED}-notes.rst") + + # Drop generated pages from earlier builds whose tags no longer exist. + for stale in out_dir.glob("*-notes.rst"): + if stale.name not in written and stale.read_text(encoding="utf-8").startswith(GENERATED_MARKER): + stale.unlink() + + +def setup(app: Sphinx) -> dict: + app.add_config_value("release_notes_dir", "", "env") + app.add_config_value("release_notes_tag_prefix", "v", "env") + app.add_config_value("release_notes_first_version", "", "env") + app.add_config_value("release_notes_title", "{version} Release notes", "env") + app.add_config_value("release_notes_prolog", "", "env") + app.add_config_value("release_notes_unreleased_title", "In development", "env") + app.add_config_value("release_notes_pr_url", "https://github.com/NVIDIA/cuda-python/pull/{number}", "env") + app.add_config_value("release_notes_output_dir", "release", "env") + app.connect("builder-inited", _generate_pages) + return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/cuda_python/docs/exts/release_ranges.py b/cuda_python/docs/exts/release_ranges.py new file mode 100644 index 00000000000..5ecab6a672a --- /dev/null +++ b/cuda_python/docs/exts/release_ranges.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The packages with per-change note files, the note sections, and which release each note belongs to. + +This module has no third-party dependencies so that the docs extension (``release_notes``), the +release gate (``ci/tools/check_release_notes.py``), the PR check, the linter and +``toolshed/add_note.py`` can all share it. See the ``release_notes`` docstring for the rules. +""" + +from __future__ import annotations + +import re +from typing import NamedTuple + +Version = tuple[int, int, int] + +#: The sections of a note, in the order and with the titles they have on a release page. A note +#: is a YAML mapping from these keys to lists of reStructuredText entries. Known issues come last. +#: ``prelude`` is not here: it is an untitled introduction at the top of a page. +SECTIONS: tuple[tuple[str, str], ...] = ( + ("features", "New Features"), + ("upgrade", "Breaking Changes"), + ("deprecations", "Deprecation Notes"), + ("critical", "Critical Issues"), + ("security", "Security Issues"), + ("fixes", "Bug Fixes"), + ("other", "Other Notes"), + ("issues", "Known Issues"), +) +PRELUDE = "prelude" + + +def section_key_re(key: str) -> str: + """The pattern a section key must match, written plainly at the start of a line. + + The docs find the notes with known issues by grepping for ``section_key_re("issues")``, so + ``lint_release_notes.py`` rejects other spellings of any key (``"issues":``, ``issues :``). + """ + return rf"^{re.escape(key)}:" + + +#: The file name of a note: a short description and a random suffix, as ``toolshed/add_note.py`` +#: makes them. +NOTE_NAME_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{16}\.yaml") + + +class NotesPackage(NamedTuple): + #: Directory with the package's note files (``*.yaml``), relative to the repo root. + notes_dir: str + #: Prefix of the package's release tags, e.g. ``v`` for ``v13.5.0``. + tag_prefix: str + #: First release whose notes are note files; earlier ones are hand-written pages. + first_version: Version + #: Changes to these paths (a trailing ``/`` marks a directory) need a release note. + #: Everything else (docs, tests, CI, pixi files, ...) does not. + source_paths: tuple[str, ...] + + +#: Packages whose release notes are built from note files, by release component name. +PACKAGES: dict[str, NotesPackage] = { + "cuda-core": NotesPackage( + "cuda_core/releasenotes", + "cuda-core-v", + (1, 3, 0), + ( + "cuda_core/cuda/", + "cuda_core/pyproject.toml", + "cuda_core/setup.py", + "cuda_core/build_hooks.py", + "cuda_core/MANIFEST.in", + ), + ), + # Releases before 1.9.0, including any 1.8.x patch releases, keep hand-written notes. Starting at a + # minor release (not a patch) keeps the first release with note files the minor release of its line. + "cuda-pathfinder": NotesPackage( + "cuda_pathfinder/releasenotes", + "cuda-pathfinder-v", + (1, 9, 0), + ("cuda_pathfinder/cuda/", "cuda_pathfinder/pyproject.toml"), + ), + # cuda-python shares the bare ``v`` tag namespace with cuda-bindings; each scans only its own notes. + "cuda-python": NotesPackage( + "cuda_python/releasenotes", + "v", + (13, 5, 0), + ("cuda_python/pyproject.toml", "cuda_python/setup.py"), + ), + "cuda-bindings": NotesPackage( + "cuda_bindings/releasenotes", + "v", + (13, 5, 0), + ( + "cuda_bindings/cuda/", + "cuda_bindings/pyproject.toml", + "cuda_bindings/setup.py", + "cuda_bindings/build_hooks.py", + "cuda_bindings/MANIFEST.in", + ), + ), +} + + +def format_version(version: Version) -> str: + return ".".join(str(part) for part in version) + + +def parse_release_tags(tags: list[str], prefix: str) -> dict[Version, str]: + """Map each plain ``X.Y.Z`` tag in *tags* to its version. + + Pre-release tags (``rc``, ``a``, ``b``), post-release tags and other + packages' tags do not match. + """ + pattern = re.compile(rf"^{re.escape(prefix)}(\d+)\.(\d+)\.(\d+)$") + releases = {} + for tag in tags: + m = pattern.match(tag) + if m: + releases[(int(m.group(1)), int(m.group(2)), int(m.group(3)))] = tag + return releases + + +def previous_release(version: Version, candidates: list[Version]) -> Version | None: + """Return the release that *version*'s notes are relative to, or None. + + The first release of an ``X.Y`` line is its **minor release**. That is + normally ``X.Y.0``, but a line may start at ``X.Y.1`` (for example when + ``X.Y.0`` was only ever a pre-release, and pre-releases are not in + *candidates*). + + * A minor release is relative to the previous line's minor release. + * Any other release is relative to the previous release of the same ``X.Y`` + line. + """ + line = version[:2] + same_line = [v for v in candidates if v[:2] == line and v < version] + if same_line: + return max(same_line) + minor_releases = {} + for v in candidates: + if v[:2] < line: + minor_releases[v[:2]] = min(v, minor_releases.get(v[:2], v)) + if not minor_releases: + return None + return minor_releases[max(minor_releases)] diff --git a/cuda_python/docs/exts/release_toc.py b/cuda_python/docs/exts/release_toc.py index ac833b1990f..2c8acf12045 100644 --- a/cuda_python/docs/exts/release_toc.py +++ b/cuda_python/docs/exts/release_toc.py @@ -6,8 +6,14 @@ from packaging.version import InvalidVersion, Version from sphinx.directives.other import TocTree +# Generated page for notes that are not yet part of any release (see release_notes). +UNRELEASED = "unreleased" +UNRELEASED_LABEL = "In development" + def _version_sort_key(version_text): + if version_text == UNRELEASED: + return (2, version_text) normalized = version_text.replace(".x", ".999999") try: return (1, Version(normalized)) @@ -15,6 +21,12 @@ def _version_sort_key(version_text): return (0, version_text) +def _label(version_text): + if version_text == UNRELEASED: + return UNRELEASED_LABEL + return version_text + + def _is_prerelease(version_text): try: version = Version(version_text) @@ -40,6 +52,7 @@ def parse_content(self, toctree): # Don't include any prereleases in the toctree entries = [entry for entry in entries if not _is_prerelease(entry[0])] entries.sort(key=lambda x: _version_sort_key(x[0]), reverse=True) + entries = [(_label(title), ref) for title, ref in entries] toctree["entries"] = entries diff --git a/cuda_python/docs/source/conf.py b/cuda_python/docs/source/conf.py index f7b447f9dac..d20b4c6f94f 100644 --- a/cuda_python/docs/source/conf.py +++ b/cuda_python/docs/source/conf.py @@ -15,6 +15,7 @@ sys.path.insert(0, str((Path(__file__).parents[1] / "exts").absolute())) +from release_ranges import PACKAGES, format_version # -- Project information ----------------------------------------------------- @@ -47,8 +48,17 @@ def _html_baseurl(): "enum_tools.autoenum", "release_toc", "release_date", + "release_notes", ] +# Release notes from 13.5.0 on are built from the notes in +# cuda_python/releasenotes; earlier ones are hand-written pages in release/. +release_notes_dir = PACKAGES["cuda-python"].notes_dir +release_notes_tag_prefix = PACKAGES["cuda-python"].tag_prefix +release_notes_first_version = format_version(PACKAGES["cuda-python"].first_version) +release_notes_title = "CUDA Python {version} Release notes" +release_notes_unreleased_title = "CUDA Python unreleased changes" + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/cuda_python/docs/source/release.rst b/cuda_python/docs/source/release.rst index f844bfc251d..b8726b4d661 100644 --- a/cuda_python/docs/source/release.rst +++ b/cuda_python/docs/source/release.rst @@ -8,4 +8,4 @@ Release Notes :maxdepth: 3 :glob: - release/*[0-9]-notes + release/*-notes diff --git a/cuda_python/docs/source/release/.gitignore b/cuda_python/docs/source/release/.gitignore new file mode 100644 index 00000000000..85281f90081 --- /dev/null +++ b/cuda_python/docs/source/release/.gitignore @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Pages generated by cuda_python/docs/exts/release_notes.py, which covers +# releases from the first reno version on (see release_ranges.PACKAGES). Releases +# before it keep their hand-written pages here. +/unreleased-notes.rst +/13.[5-9]*-notes.rst +/13.[1-9][0-9]*-notes.rst +/1[4-9]*-notes.rst +/[2-9][0-9]*-notes.rst +/[1-9][0-9][0-9]*-notes.rst diff --git a/cuda_python/releasenotes/pip-upgrade-from-old-versions-5b1e0c7a93d24f68.yaml b/cuda_python/releasenotes/pip-upgrade-from-old-versions-5b1e0c7a93d24f68.yaml new file mode 100644 index 00000000000..d35c455e3a0 --- /dev/null +++ b/cuda_python/releasenotes/pip-upgrade-from-old-versions-5b1e0c7a93d24f68.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +issues: + - | + Updating from older versions (v12.6.2.post1 and below) via ``pip install -U cuda-python`` might + not work. Please do a clean re-installation by uninstalling ``pip uninstall -y cuda-python`` + followed by installing ``pip install cuda-python``. (#626) diff --git a/toolshed/add_note.py b/toolshed/add_note.py new file mode 100755 index 00000000000..60dd9d03ef4 --- /dev/null +++ b/toolshed/add_note.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.10" +# /// + +"""Create a new release note for one of the packages. + +Usage: + python toolshed/add_note.py PACKAGE SHORT-DESCRIPTION [--edit] + +PACKAGE is one of the packages that have release notes (``cuda-bindings``, ``cuda-core``, +``cuda-pathfinder`` or ``cuda-python``; the ``cuda-`` prefix is optional and ``_`` also works). +SHORT-DESCRIPTION is a few words that become part of the file name. It can be run from any +directory: the note is always created in the package's ``releasenotes`` directory of this checkout. + +Edit the new file: keep the sections that apply, delete the rest, and replace the ``TODO`` text. +See "Release notes" in CONTRIBUTING.md. +""" + +from __future__ import annotations + +import argparse +import datetime +import os +import re +import secrets +import shlex +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "cuda_python" / "docs" / "exts")) +import release_ranges # noqa: E402 + +# The sections must be those of release_ranges.SECTIONS (checked by the tests). +TEMPLATE = """\ +# SPDX-FileCopyrightText: Copyright (c) {year} NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Keep only the sections that apply and delete the rest. Each entry is reStructuredText, wrapped at +# 100 columns. A link to the pull request is added automatically. +# +# Entries under ``issues`` (known issues) are different: they are listed in the release notes of +# every release until the entry is removed, so remove the entry in the PR that resolves the issue. +--- +features: + - | + TODO: New functionality. +upgrade: + - | + TODO: Breaking changes, including behavior changes users may need to act on. +deprecations: + - | + TODO: Newly deprecated functionality. +critical: + - | + TODO: Critical issues users must know about. +security: + - | + TODO: Security issues. +fixes: + - | + TODO: Bug fixes. +other: + - | + TODO: Anything else. +issues: + - | + TODO: Known issues. +""" + +MAX_SLUG_LENGTH = 60 + + +def normalize_package(name: str) -> str: + """Spell a user-supplied package name as a component name: ``Core`` -> ``cuda-core``.""" + component = name.strip().lower().replace("_", "-") + if not component.startswith("cuda-"): + component = f"cuda-{component}" + return component + + +def find_package(name: str) -> str: + """Return the component name (``cuda-core``) for a user-supplied package name.""" + component = normalize_package(name) + if component not in release_ranges.PACKAGES: + choices = ", ".join(sorted(release_ranges.PACKAGES)) + raise ValueError(f"unknown package {name!r}; choose one of: {choices}") + return component + + +def slugify(description: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", description.lower()).strip("-")[:MAX_SLUG_LENGTH].strip("-") + if not slug: + raise ValueError(f"the description {description!r} has no letters or digits to make a file name from") + return slug + + +def create_note(package: str, description: str, repo_root: Path | None = None) -> Path: + if repo_root is None: + repo_root = REPO_ROOT + component = find_package(package) + notes_dir = repo_root / release_ranges.PACKAGES[component].notes_dir + notes_dir.mkdir(parents=True, exist_ok=True) + path = notes_dir / f"{slugify(description)}-{secrets.token_hex(8)}.yaml" + path.write_text( + TEMPLATE.replace("{year}", str(datetime.datetime.now(tz=datetime.timezone.utc).year)), encoding="utf-8" + ) + return path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "package", + type=normalize_package, + choices=sorted(release_ranges.PACKAGES), + help="the package to add a release note to (the cuda- prefix is optional)", + ) + parser.add_argument("description", help="a few words describing the change, used in the file name") + parser.add_argument("--edit", action="store_true", help="open the new file in $VISUAL or $EDITOR") + args = parser.parse_args(argv) + + try: + path = create_note(args.package, args.description) + except ValueError as e: + print(f"ERROR: {e}", file=sys.stderr) + return 2 + print(f"Created {path.relative_to(REPO_ROOT).as_posix()}") + + if args.edit: + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") + if not editor: + print("ERROR: set $VISUAL or $EDITOR to use --edit.", file=sys.stderr) + return 2 + return subprocess.run([*shlex.split(editor), str(path)], check=False).returncode # noqa: S603 + return 0 + + +if __name__ == "__main__": + sys.exit(main())