diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cd6320fe0d..00e9c365de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,28 +1,5 @@ -# Copyright 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * Neither the name of NVIDIA CORPORATION nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY -# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY -# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause repos: - repo: https://github.com/PyCQA/isort @@ -82,3 +59,18 @@ repos: stages: [pre-commit] verbose: true require_serial: true + # Incremental SPDX-header adoption: migrate each source file to the two-line + # SPDX header the first time it is touched. pre-commit runs hooks only on the + # files staged in a commit, so scoping by file type (below) -- not by directory + # -- rolls SPDX out gradually as files change. The hook maintains an existing + # SPDX header, replaces a legacy long-form NVIDIA BSD header in place, or + # inserts one. It runs after add-license above, which only maintains the + # copyright year on the string both headers share. + - id: add-spdx-license + name: Add SPDX License Header + entry: python tools/add_spdx_header.py + language: python + files: \.(py|pyi|sh|bash|yaml|yml|cc|cpp|cxx|h|hpp|cu|cuh)$ + stages: [pre-commit] + verbose: true + require_serial: true diff --git a/build.py b/build.py index b6aee9cac3..b473b76739 100755 --- a/build.py +++ b/build.py @@ -1,29 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * Neither the name of NVIDIA CORPORATION nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY -# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY -# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause import argparse import importlib.util @@ -2556,6 +2533,20 @@ def enable_all(): required=False, help="Override specified backend CMake argument in the build as :=. The argument is passed to CMake as -D=. This flag only impacts CMake arguments that are used by build.py. To unconditionally add a CMake argument to the backend build use --extra-backend-cmake-arg.", ) + parser.add_argument( + "--build-presets-file", + type=str, + required=False, + default=None, + help="(EXPERIMENTAL; requires TRITON_BUILD_EXPERIMENTAL=1) Path to a build " + "presets JSON file that pins per-component cmake flags (tag, cmake_args, " + "extra_cmake_args, library_path) for core/backends/repoagents/caches. " + "Command-line flags take precedence over the file. With --dryrun, a " + "provenance-annotated snapshot of every resolved cmake flag (labeled " + "cli/preset/default) is written to build_presets.json in the build " + "directory; that file can be loaded back with this flag. See " + "tools/build/build_presets.py for the schema.", + ) parser.add_argument( "--release-version", required=False, @@ -2870,6 +2861,45 @@ def enable_all(): OVERRIDE_BACKEND_CMAKE_FLAGS[be] = {} OVERRIDE_BACKEND_CMAKE_FLAGS[be][parts[0]] = parts[1] + # Per-backend clone-organization overrides. Empty unless the experimental + # --build-presets-file supplies a TRITON_REPO_ORGANIZATION for a backend, in + # which case the backend build loop below clones from that organization. + backend_org_overrides = {} + + # Snapshots captured BEFORE applying a preset, so the --dryrun dump can tell + # which values came from the CLI vs the preset file vs build.py defaults. + _bp_before_backends = dict(backends) + _bp_before_repoagents = dict(repoagents) + _bp_before_caches = dict(caches) + _bp_cli_extra_be = {be: set(m) for be, m in EXTRA_BACKEND_CMAKE_FLAGS.items()} + _bp_cli_override_be = {be: set(m) for be, m in OVERRIDE_BACKEND_CMAKE_FLAGS.items()} + _bp_cli_core = set(EXTRA_CORE_CMAKE_FLAGS) | set(OVERRIDE_CORE_CMAKE_FLAGS) + + # Experimental: apply a build preset (all load/validate/precedence logic lives + # in tools/build/build_presets.py). Command-line flags win over the file. + if FLAGS.build_presets_file is not None: + from tools.build import build_presets + + try: + for msg in build_presets.apply( + FLAGS.build_presets_file, + backends=backends, + repoagents=repoagents, + caches=caches, + cli_backend_specs=FLAGS.backend, + cli_repoagent_specs=FLAGS.repoagent, + cli_cache_specs=FLAGS.cache, + library_paths=library_paths, + extra_backend_flags=EXTRA_BACKEND_CMAKE_FLAGS, + override_backend_flags=OVERRIDE_BACKEND_CMAKE_FLAGS, + extra_core_flags=EXTRA_CORE_CMAKE_FLAGS, + override_core_flags=OVERRIDE_CORE_CMAKE_FLAGS, + backend_org_overrides=backend_org_overrides, + ): + log(msg) + except build_presets.BuildPresetError as e: + fail(str(e)) + # Initialize map of common components and repo-tag for each. components = { "common": default_repo_tag, @@ -2907,6 +2937,61 @@ def enable_all(): # Write the build script that invokes cmake for the core, backends, repo-agents, and caches. pathlib.Path(FLAGS.build_dir).mkdir(parents=True, exist_ok=True) + + # Experimental: on a --dryrun, emit a provenance-annotated snapshot of the + # fully-resolved cmake configuration -- every -D each component receives, as + # in cmake_build -- to /build_presets.json, labeling each value's + # source (cli / preset / default). The file can be loaded back with + # --build-presets-file to pin every flag. Gated behind + # TRITON_BUILD_EXPERIMENTAL=1. + if FLAGS.dryrun and os.getenv("TRITON_BUILD_EXPERIMENTAL") == "1": + from tools.build import build_presets + + _bp_be = [b for b in backends if b not in CORE_BACKENDS] + try: + for msg in build_presets.write_snapshot( + os.path.join(FLAGS.build_dir, "build_presets.json"), + parser=parser, + argv=sys.argv, + core_args=core_cmake_args( + components, backends, script_cmake_dir, script_install_dir + ), + backend_args={ + be: backend_cmake_args( + images, components, be, script_install_dir, library_paths + ) + for be in _bp_be + }, + repoagent_args={ + ra: repoagent_cmake_args(images, components, ra, script_install_dir) + for ra in repoagents + }, + cache_args={ + ca: cache_cmake_args(images, components, ca, script_install_dir) + for ca in caches + }, + backends=backends, + repoagents=repoagents, + caches=caches, + before_backends=_bp_before_backends, + before_repoagents=_bp_before_repoagents, + before_caches=_bp_before_caches, + cli_backend_specs=FLAGS.backend, + cli_repoagent_specs=FLAGS.repoagent, + cli_cache_specs=FLAGS.cache, + cli_extra_be=_bp_cli_extra_be, + cli_override_be=_bp_cli_override_be, + cli_core=_bp_cli_core, + extra_backend_flags=EXTRA_BACKEND_CMAKE_FLAGS, + override_backend_flags=OVERRIDE_BACKEND_CMAKE_FLAGS, + extra_core_flags=EXTRA_CORE_CMAKE_FLAGS, + override_core_flags=OVERRIDE_CORE_CMAKE_FLAGS, + library_paths=library_paths, + ): + log(msg) + except build_presets.BuildPresetError as e: + fail(str(e)) + with BuildScript( os.path.join(FLAGS.build_dir, script_name), verbose=FLAGS.verbose, @@ -2940,7 +3025,9 @@ def enable_all(): if be == "armnn_tflite": github_organization = "https://gitlab.com/arm-research/smarter/" else: - github_organization = FLAGS.github_organization + github_organization = backend_org_overrides.get( + be, FLAGS.github_organization + ) if be == "vllm": backend_clone( diff --git a/docs/customization_guide/build.md b/docs/customization_guide/build.md index 48495c7211..b37f579339 100644 --- a/docs/customization_guide/build.md +++ b/docs/customization_guide/build.md @@ -180,6 +180,75 @@ you have a branch called "mybranch" in the repo that you want to use in the build, you would specify --backend=onnxruntime:mybranch. +#### Experimental: Build Presets + +> **Experimental.** This feature is gated behind the +> `TRITON_BUILD_EXPERIMENTAL=1` environment variable; without it the +> `--build-presets-file` flag is rejected. + +Build presets let you (a) inspect exactly which cmake flags each component +receives, and (b) pin those flags via a single JSON file with +`--build-presets-file `. + +**Snapshot (dump).** When run with `--dryrun`, build.py writes a +provenance-annotated snapshot of the fully-resolved cmake configuration to +`build_presets.json` in the build directory. It records, for `core`, each +`backend`, `repoagent`, and `cache`, every `-D` flag that lands in `cmake_build`, +each labeled with its `source`: `cli` (explicit command line), `preset` (from a +loaded presets file), or `default` (build.py default/derived): + +```json +{ + "backends": { + "onnxruntime": { + "tag": { "value": "main", "source": "default" }, + "cmake_args": { + "TRITON_ENABLE_GPU": { "value": "ON", "source": "cli" }, + "TRITON_BUILD_ONNXRUNTIME_VERSION": { "value": "1.27.0", "source": "default" } + } + } + } +} +``` + +**Reload.** That same file can be fed back with `--build-presets-file` to pin its +flags (the `source` field is informational on load). A hand-written preset may +use bare scalars instead of `{value, source}` objects: + +```json +{ + "backends": { + "onnxruntime": { + "tag": "r25.08_fix", + "cmake_args": { "TRITON_ENABLE_ONNXRUNTIME_OPENVINO": "OFF" } + }, + "python": { + "extra_cmake_args": { "TRITON_BOOST_URL": "https://.../boost_1_80_0.tar.gz" } + } + } +} +``` + +Notes: +- A component named in the file must also be included in the build (via + `--backend`/`--repoagent`/`--cache` or `--enable-all`). +- Command-line flags always take precedence over the file. +- `cmake_args` are flags build.py emits natively (applied via the override + channel); `extra_cmake_args` are user-added flags build.py does not emit + (applied via the append channel). Setting `TRITON_REPO_ORGANIZATION` in a + backend's `cmake_args` sets that backend's clone organization *per backend* + (both the `git clone` URL and the `-D`), which the global + `--github-organization` cannot do. +- `CMAKE_INSTALL_PREFIX` is omitted from snapshots (it is an absolute build-dir + path). Repoagent/cache `cmake_args` are shown for visibility but only their + `tag` is re-pinned on load. Reloading pins `-D` values but does not re-derive + conditionally-emitted flags, so reload alongside the same top-level flags (or + `--enable-all`) for exact reproduction. + +A ready-to-copy example lives at +[`tools/build/build_presets.example.json`](../../tools/build/build_presets.example.json); +the full schema is documented in `tools/build/build_presets.py`. + #### CPU-Only Build If you want to build without GPU support you must specify individual diff --git a/qa/L2_build_presets/README.md b/qa/L2_build_presets/README.md new file mode 100644 index 0000000000..870e1864d5 --- /dev/null +++ b/qa/L2_build_presets/README.md @@ -0,0 +1,46 @@ + + +# L2_build_presets + +Validates the experimental **build presets** feature +(`docs/customization_guide/build.md`) by running `build.py --dryrun` and checking +the generated `build_presets.json`. No GPU, container, or real build needed. + +## Run + +```bash +cd qa/L2_build_presets +bash test.sh # installs deps, runs pytest, writes logs +``` + +Or directly: `python3 -m pytest build_presets_test.py`. + +## Finding build.py + +`build.py` lives in the server repo. It is located in-tree (a checkout), or +cloned when only this directory is present. Override via: + +| Env var | Meaning | Default | +|---|---|---| +| `TRITON_BUILD_PY` | explicit path to `build.py` | — | +| `TRITON_SERVER_REPO` | repo to clone when not found | `.../triton-inference-server/server.git` | +| `TRITON_SERVER_BRANCH_NAME` | branch/tag to clone — **must exist on the remote** | `main` | + +Bare container, mounting only this directory: + +```bash +cd server/ +docker run --rm -v "$PWD":/workspace -w /workspace \ + -e TRITON_SERVER_BRANCH_NAME= \ + -w /workspace/qa/L2_build_presets \ + nvcr.io/nvidia/tritonserver:26.06-py3 python3 build_presets_test.py +``` + +## Output + +`test.sh` writes `build_presets_test.log` (full console) and +`build_presets_test.report.xml` (JUnit) — both git-ignored — and prints the +`*** Test Passed ***` / `*** Test FAILED ***` markers. diff --git a/qa/L2_build_presets/build_presets_test.py b/qa/L2_build_presets/build_presets_test.py new file mode 100644 index 0000000000..30c24c9cbd --- /dev/null +++ b/qa/L2_build_presets/build_presets_test.py @@ -0,0 +1,378 @@ +# SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause + +"""Validate the documented experimental build-presets scenarios. + +Reference: ``server/docs/customization_guide/build.md`` -> "Experimental: Build +Presets". Every test drives ``build.py`` in ``--dryrun`` mode only -- no GPU, no +container, and no real build are required; build.py just generates the +``cmake_build`` script and the ``build_presets.json`` snapshot, which the tests +inspect. + +Self-sufficient: it finds ``build.py`` in-tree (source checkout), via +``TRITON_BUILD_PY``, or by cloning the server repo when only this directory is +present (bare container). See the README to run it. +""" + +import json +import logging +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# build.py command diagnostics; shown when pytest logs at INFO level +# (test.sh runs with --log-cli-level=INFO). +LOGGER = logging.getLogger("l2_build_presets") + +# build.py lives in the server repo. When only this test directory is available +# (e.g. a bare container), it is cloned; override the source with these env vars. +# The ref must contain the experimental build-presets feature. +SERVER_REPO = os.environ.get( + "TRITON_SERVER_REPO", "https://github.com/triton-inference-server/server.git" +) +SERVER_BRANCH_NAME = os.environ.get("TRITON_SERVER_BRANCH_NAME", "main") + + +def _resolve_server_dir(): + override = os.environ.get("TRITON_BUILD_PY") + if override: + return Path(override).resolve().parent + for parent in Path(__file__).resolve().parents: + if (parent / "build.py").is_file(): + LOGGER.info("build.py found in-tree: %s", parent) + return parent + dest = Path(tempfile.gettempdir()) / "triton-server-under-test" + if not (dest / "build.py").is_file(): + LOGGER.info( + "build.py not in-tree; cloning %s@%s", SERVER_REPO, SERVER_BRANCH_NAME + ) + shutil.rmtree(dest, ignore_errors=True) + try: + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + SERVER_BRANCH_NAME, + SERVER_REPO, + str(dest), + ], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = getattr(exc, "stderr", "") or str(exc) + raise RuntimeError( + "build.py not found in-tree and clone of {}@{} failed: {}\nSet " + "TRITON_BUILD_PY, or TRITON_SERVER_REPO / TRITON_SERVER_BRANCH_NAME.".format( + SERVER_REPO, SERVER_BRANCH_NAME, detail + ) + ) + return dest + + +# Session cache for the resolved server dir: {"dir": Path} on success or +# {"error": Exception} on failure (mutated in place -- no reassignment). +_SERVER_DIR_CACHE = {} + + +def server_dir(): + """Locate the server repo root holding build.py: ``TRITON_BUILD_PY`` override + -> in-tree (upward search) -> a shallow clone of ``SERVER_REPO`` at + ``SERVER_BRANCH_NAME``. The resolution (success OR failure) is cached, so a + failed clone is attempted once and then re-raised fast for the remaining + tests instead of re-cloning per test.""" + if "error" in _SERVER_DIR_CACHE: + raise _SERVER_DIR_CACHE["error"] + if "dir" not in _SERVER_DIR_CACHE: + try: + _SERVER_DIR_CACHE["dir"] = _resolve_server_dir() + except Exception as exc: + _SERVER_DIR_CACHE["error"] = exc + raise + return _SERVER_DIR_CACHE["dir"] + + +def build_py(): + return server_dir() / "build.py" + + +def example_preset(): + return server_dir() / "tools" / "build" / "build_presets.example.json" + + +# A representative invocation that exercises every snapshot section. +BASE_ARGS = [ + "--backend", "ensemble", + "--backend", "python", + "--backend", "onnxruntime", + "--repoagent", "checksum", + "--cache", "local", + "--enable-logging", + "--enable-stats", + "--enable-gpu", + "--endpoint", "http", + "--endpoint", "grpc", +] # fmt: skip + + +def run_build(build_dir, extra_args=(), *, experimental=True, verbose=False): + """Run ``build.py`` in --dryrun mode into ``build_dir``. Returns the + completed process (returncode / stdout / stderr).""" + Path(build_dir).mkdir(parents=True, exist_ok=True) + cmd = [ + sys.executable, + str(build_py()), + "--no-container-build", + "--build-dir", + str(build_dir), + ] + cmd += list(extra_args) + cmd += ["--dryrun", "-v" if verbose else "-q"] + env = dict(os.environ) + if experimental: + env["TRITON_BUILD_EXPERIMENTAL"] = "1" + else: + env.pop("TRITON_BUILD_EXPERIMENTAL", None) + # Log a copy-paste-runnable command (env prefix + full argv, shell-quoted). + prefix = "TRITON_BUILD_EXPERIMENTAL=1 " if experimental else "" + LOGGER.info("run (cwd=%s):\n %s%s", server_dir(), prefix, shlex.join(cmd)) + proc = subprocess.run( + cmd, cwd=str(server_dir()), env=env, capture_output=True, text=True + ) + LOGGER.info(" -> returncode %d", proc.returncode) + return proc + + +def load_snapshot(build_dir): + """Parse the build_presets.json snapshot from a --dryrun run.""" + return json.loads((Path(build_dir) / "build_presets.json").read_text()) + + +def read_cmake_build(build_dir): + """Read the generated cmake_build script from a --dryrun run.""" + return (Path(build_dir) / "cmake_build").read_text() + + +def dump_snapshot(build_dir, extra_args=()): + """Run a dump with BASE_ARGS + extra_args and return the parsed snapshot.""" + proc = run_build(build_dir, list(extra_args) + BASE_ARGS) + assert proc.returncode == 0, proc.stderr + return load_snapshot(build_dir) + + +# --------------------------------------------------------------------------- # +# Documented-scenario tests +# --------------------------------------------------------------------------- # +def test_dryrun_generates_snapshot_with_all_sections(tmp_path): + """--dryrun writes build_presets.json covering all four sections: core, + backends, repoagents, caches.""" + snap = dump_snapshot(tmp_path) + for section in ("core", "backends", "repoagents", "caches"): + assert section in snap, section + assert "onnxruntime" in snap["backends"] + assert "checksum" in snap["repoagents"] + assert "local" in snap["caches"] + + +def test_every_flag_is_source_annotated(tmp_path): + """Every cmake flag in the snapshot carries a {value, source} pair, and each + source is one of cli/preset/default.""" + snap = dump_snapshot(tmp_path) + sources = set() + for flag, entry in snap["core"]["cmake_args"].items(): + assert set(entry) == {"value", "source"}, flag + sources.add(entry["source"]) + assert sources <= {"cli", "preset", "default"} + + +def test_provenance_cli_vs_default(tmp_path): + """Provenance is correct: a flag whose CLI option was passed (--enable-gpu) + is labeled 'cli'; an unset flag (--build-type) is labeled 'default'.""" + core = dump_snapshot(tmp_path)["core"]["cmake_args"] + assert core["TRITON_ENABLE_GPU"]["source"] == "cli" + assert core["TRITON_ENABLE_GPU"]["value"] == "ON" + assert core["CMAKE_BUILD_TYPE"]["source"] == "default" + + +def test_install_prefix_excluded_from_snapshot(tmp_path): + """CMAKE_INSTALL_PREFIX (an absolute build-dir path) is excluded from the + snapshot so it is not pinned across build directories.""" + snap = dump_snapshot(tmp_path) + assert "CMAKE_INSTALL_PREFIX" not in snap["core"]["cmake_args"] + + +def test_user_added_flag_lands_in_extra_channel(tmp_path): + """A user-added flag (--extra-backend-cmake-arg) that build.py does not emit + natively is recorded in the backend's extra_cmake_args channel, not + cmake_args.""" + snap = dump_snapshot( + tmp_path, + extra_args=[ + "--extra-backend-cmake-arg", + "python:TRITON_BOOST_URL=https://example.com/boost.tar.gz", + ], + ) + py = snap["backends"]["python"] + assert "TRITON_BOOST_URL" in py.get("extra_cmake_args", {}) + assert py["extra_cmake_args"]["TRITON_BOOST_URL"]["source"] == "cli" + + +def test_env_gate_rejects_without_experimental(tmp_path): + """The experimental gate holds: without TRITON_BUILD_EXPERIMENTAL=1, + --build-presets-file errors out and no snapshot is written.""" + proc = run_build( + tmp_path, + BASE_ARGS + ["--build-presets-file", str(example_preset())], + experimental=False, + ) + assert proc.returncode != 0 + assert "TRITON_BUILD_EXPERIMENTAL" in proc.stderr + assert not (tmp_path / "build_presets.json").exists() + + +def test_example_preset_applies(tmp_path): + """The shipped example preset applies: onnxruntime is cloned at the file's + tag from the file's TRITON_REPO_ORGANIZATION.""" + proc = run_build( + tmp_path, + [ + "--backend", + "ensemble", + "--backend", + "python", + "--backend", + "pytorch", + "--backend", + "onnxruntime", + "--enable-gpu", + "--build-presets-file", + str(example_preset()), + ], + ) + assert proc.returncode == 0, proc.stderr + cmake = read_cmake_build(tmp_path) + assert "triton-inference-server/onnxruntime_backend.git" in cmake + assert "-b r25.08_fix" in cmake # onnxruntime tag from the example + + +def test_roundtrip_reload_pins_flags(tmp_path): + """Round-trip: a dumped snapshot reloads and pins its flags -- the extra arg + and core GPU=ON reproduce even without the original CLI flags.""" + dump_dir = tmp_path / "dump" + proc = run_build( + dump_dir, + BASE_ARGS + + [ + "--extra-backend-cmake-arg", + "python:TRITON_BOOST_URL=https://example.com/boost.tar.gz", + ], + ) + assert proc.returncode == 0, proc.stderr + preset = dump_dir / "build_presets.json" + + reload_dir = tmp_path / "reload" + proc = run_build( + reload_dir, + [ + "--backend", + "ensemble", + "--backend", + "python", + "--backend", + "onnxruntime", + "--repoagent", + "checksum", + "--cache", + "local", + "--build-presets-file", + str(preset), + ], + ) + assert proc.returncode == 0, proc.stderr + cmake = read_cmake_build(reload_dir) + assert "TRITON_BOOST_URL=https://example.com/boost.tar.gz" in cmake + assert "-DTRITON_ENABLE_GPU:BOOL=ON" in cmake # pinned core flag + + +def test_cli_tag_wins_over_preset(tmp_path): + """Precedence: an explicit CLI '--backend onnxruntime:from_cli' tag wins over + the preset file's tag.""" + preset = tmp_path / "preset.json" + preset.write_text(json.dumps({"backends": {"onnxruntime": {"tag": "from_preset"}}})) + proc = run_build( + tmp_path, + [ + "--backend", + "ensemble", + "--backend", + "onnxruntime:from_cli", + "--build-presets-file", + str(preset), + ], + ) + assert proc.returncode == 0, proc.stderr + cmake = read_cmake_build(tmp_path) + assert "-b from_cli " in cmake + assert "from_preset" not in cmake + + +def test_backend_not_in_build_is_rejected(tmp_path): + """Validation: a preset naming a backend not included in the build fails with + a clear 'not included in the build' error.""" + preset = tmp_path / "preset.json" + preset.write_text(json.dumps({"backends": {"nope": {"tag": "x"}}})) + proc = run_build( + tmp_path, ["--backend", "ensemble", "--build-presets-file", str(preset)] + ) + assert proc.returncode != 0 + assert "not included in the build" in proc.stderr + + +def test_unknown_component_key_is_rejected(tmp_path): + """Validation: a preset with an unknown per-component key (typo) fails with a + clear 'unknown key' error.""" + preset = tmp_path / "preset.json" + preset.write_text(json.dumps({"backends": {"onnxruntime": {"taag": "x"}}})) + proc = run_build( + tmp_path, + [ + "--backend", + "ensemble", + "--backend", + "onnxruntime", + "--build-presets-file", + str(preset), + ], + ) + assert proc.returncode != 0 + assert "unknown key" in proc.stderr + + +if __name__ == "__main__": + # Self-sufficient standalone entry (`python3 build_presets_test.py`): install + # this suite's deps (pytest + build.py's distro/requests), then run the tests. + # build.py itself is located/cloned by server_dir(). Set L2_NO_BOOTSTRAP=1 to + # skip the install (e.g. deps already present). + if not os.environ.get("L2_NO_BOOTSTRAP"): + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "-q", + "-r", + str(Path(__file__).with_name("requirements.txt")), + ] + ) + import pytest + + raise SystemExit(pytest.main([__file__])) diff --git a/qa/L2_build_presets/requirements.txt b/qa/L2_build_presets/requirements.txt new file mode 100644 index 0000000000..564d93af84 --- /dev/null +++ b/qa/L2_build_presets/requirements.txt @@ -0,0 +1,3 @@ +distro +pytest +requests diff --git a/qa/L2_build_presets/test.sh b/qa/L2_build_presets/test.sh new file mode 100755 index 0000000000..329c100aea --- /dev/null +++ b/qa/L2_build_presets/test.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause + +# Validate the documented build-presets scenarios via build.py --dryrun (no GPU, +# container, or real build). Runs from the server source tree; see README.md. + +TEST_LOG="build_presets_test.log" +TEST_REPORT="build_presets_test.report.xml" +RET=0 +python3 -m pip install --quiet -r requirements.txt >/dev/null 2>&1 || true + +# tee: stream to the console AND persist a full log for CI archiving. +# --junitxml= writes machine-readable results (NOTE: the '=' is required; +# 'pytest --junitxml FILE.py' would treat FILE.py as the output path). +# PIPESTATUS[0] captures pytest's exit code, not tee's. +python3 -m pytest -v --log-cli-level=INFO --junitxml="$TEST_REPORT" build_presets_test.py 2>&1 | tee "$TEST_LOG" +RET=${PIPESTATUS[0]} + +if [ $RET -eq 0 ]; then + echo -e "\n***\n*** Test Passed\n***" +else + echo -e "\n***\n*** Test FAILED\n***" +fi +exit $RET diff --git a/tools/add_spdx_header.py b/tools/add_spdx_header.py new file mode 100644 index 0000000000..f78b90b758 --- /dev/null +++ b/tools/add_spdx_header.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause + +"""Insert / maintain / migrate to an SPDX license header on source files. + +Pre-commit hook that adopts the two-line SPDX header on every file it is run +against. Because pre-commit runs hooks on the files staged in a commit, scoping +this hook by file type (rather than by directory) migrates each source file to +SPDX *the first time it is touched* -- a low-risk, incremental rollout. + +The header uses a copyright *year range*, mirroring the repo convention (the +``LICENSE`` file and ``add_copyright.py`` use ``-``):: + + # SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + # SPDX-License-Identifier: BSD-3-Clause + +Per file, the hook does exactly one of: + +* **Maintain** -- already SPDX: bump the copyright end year to the current year. +* **Migrate** -- carries the legacy long-form NVIDIA BSD header: replace that + whole block in place with the two SPDX lines, preserving the comment style + (``#`` or ``//``), the shebang, and the block's start year. +* **Insert** -- no NVIDIA header: add the SPDX header (after a shebang if any). + +Idempotent, and coexists with ``add_copyright.py`` (which runs first and only +maintains the copyright year on the legacy/SPDX string it recognizes). +""" + +import os +import re +import sys +from datetime import datetime + +ROOT_DIR = os.path.join(os.path.dirname(__file__), os.pardir) +LICENSE_PATH = os.path.join(ROOT_DIR, "LICENSE") +CURRENT_YEAR = str(datetime.now().year) + +_SPDX_MARKER = "SPDX-License-Identifier" +_LICENSE_ID = "BSD-3-Clause" + +# Start year of the project-wide LICENSE (used only when inserting into a file +# that has no NVIDIA copyright at all). +_LICENSE_YEAR_PAT = re.compile(r"Copyright \(c\) (\d{4})(?:-\d{4})?, NVIDIA") +# Existing SPDX copyright line (for year maintenance). +_SPDX_YEAR_PAT = re.compile( + r"(SPDX-FileCopyrightText: Copyright \(c\) )(\d{4})(?:-\d{4})?(, NVIDIA)" +) +# First line of a legacy long-form NVIDIA BSD header (captures comment prefix + +# start year). The block ends at the standard BSD "SUCH DAMAGE" line. +_LEGACY_COPY_PAT = re.compile( + r"^(#|//) ?Copyright(?: \(c\))? (\d{4})(?:-\d{4})?,? NVIDIA CORPORATION" +) +_LEGACY_END_MARK = "POSSIBILITY OF SUCH DAMAGE" + +_CPP_EXTS = (".cc", ".cpp", ".cxx", ".h", ".hpp", ".cu", ".cuh") + + +def _year_range(start): + return start if start == CURRENT_YEAR else "{}-{}".format(start, CURRENT_YEAR) + + +def _license_start_year(): + try: + with open(LICENSE_PATH, "r", encoding="utf-8") as f: + match = _LICENSE_YEAR_PAT.search(f.read()) + if match: + return match.group(1) + except OSError: + # LICENSE not readable here; fall through to the current-year default. + pass + return CURRENT_YEAR + + +def _spdx_lines(prefix, years): + return ( + "{p} SPDX-FileCopyrightText: Copyright (c) {y}, NVIDIA CORPORATION " + "& AFFILIATES. All rights reserved.\n" + "{p} SPDX-License-Identifier: {lic}\n".format( + p=prefix, y=years, lic=_LICENSE_ID + ) + ) + + +def _maintain(content): + def _bump(match): + return match.group(1) + _year_range(match.group(2)) + match.group(3) + + return _SPDX_YEAR_PAT.sub(_bump, content) + + +def _migrate_legacy(content): + """Replace a legacy long-form NVIDIA BSD header with the SPDX lines. Returns + the new content, or None if no legacy header is found.""" + lines = content.splitlines(keepends=True) + start = prefix = start_year = None + for i, line in enumerate(lines): + match = _LEGACY_COPY_PAT.match(line) + if match: + start, prefix, start_year = i, match.group(1), match.group(2) + break + if start is None: + return None + end = None + for j in range(start, len(lines)): + if _LEGACY_END_MARK in lines[j]: + end = j + break + if end is None: + return None + header = _spdx_lines(prefix, _year_range(start_year)) + return "".join(lines[:start]) + header + "".join(lines[end + 1 :]) + + +def _insert(path, content): + prefix = "//" if path.endswith(_CPP_EXTS) else "#" + header = _spdx_lines(prefix, _year_range(_license_start_year())) + "\n" + lines = content.splitlines(keepends=True) + if lines and lines[0].startswith("#!"): + return lines[0] + header + "".join(lines[1:]) + return header + content + + +def process(path): + """Bring ``path`` to an SPDX header (maintain/migrate/insert). Returns True if + the file was modified.""" + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + except (OSError, UnicodeDecodeError): + return False + + if _SPDX_MARKER in content: + updated = _maintain(content) + else: + updated = _migrate_legacy(content) + if updated is None: + updated = _insert(path, content) + + if updated == content: + return False + with open(path, "w", encoding="utf-8") as f: + f.write(updated) + return True + + +def main(argv): + changed = False + for path in argv[1:]: + if process(path): + print("Updated SPDX header: {}".format(path)) + changed = True + # Non-zero exit tells pre-commit the file was modified so it re-stages. + return 1 if changed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/build/build_presets.example.json b/tools/build/build_presets.example.json new file mode 100644 index 0000000000..4dfa5f1e63 --- /dev/null +++ b/tools/build/build_presets.example.json @@ -0,0 +1,19 @@ +{ + "backends": { + "onnxruntime": { + "tag": "r25.08_fix", + "cmake_args": { + "TRITON_REPO_ORGANIZATION": "https://github.com/triton-inference-server", + "TRITON_ENABLE_ONNXRUNTIME_OPENVINO": "OFF" + } + }, + "pytorch": { + "tag": "protect_thread_set" + }, + "python": { + "extra_cmake_args": { + "TRITON_BOOST_URL": "https://archives.boost.io/release/1.80.0/source/boost_1_80_0.tar.gz" + } + } + } +} diff --git a/tools/build/build_presets.py b/tools/build/build_presets.py new file mode 100644 index 0000000000..1e7788feee --- /dev/null +++ b/tools/build/build_presets.py @@ -0,0 +1,613 @@ +# SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause + +"""Experimental build presets for build.py. + +This supporting module implements the experimental ``--build-presets-file`` flag, +gated behind the ``TRITON_BUILD_EXPERIMENTAL=1`` environment variable. + +Two directions: + +* ``dump()`` writes a *complete, provenance-annotated snapshot* of the resolved + cmake configuration to ``/build_presets.json`` on a --dryrun. Every + cmake ``-D`` flag each component (core / backends / repoagents / caches) + receives -- exactly what lands in ``cmake_build`` -- is recorded together with + the git tag, each annotated with its ``source``: ``cli`` (explicit command + line), ``preset`` (from a loaded presets file), or ``default`` (build.py + default/derived). + +* ``apply()`` loads that same file back and *pins every flag* so the build is + reproduced exactly. The ``source`` field is informational on load. Explicit + command-line flags still win over the file. + +Schema (produced by dump(), accepted by apply()):: + + { + "core": { "cmake_args": {...}, "extra_cmake_args": {...} }, + "backends": { "": { "tag": {"value":..,"source":..}, + "cmake_args": {...}, # -> override channel + "extra_cmake_args": {...}, # -> extra (append) channel + "library_path": {...} } }, + "repoagents": { "": { "tag": {...}, "cmake_args": {...} } }, + "caches": { "": { "tag": {...}, "cmake_args": {...} } } + } + +A cmake value / tag may be a bare scalar or a ``{"value", "source"}`` object; on +load only ``value`` is used. ``cmake_args`` holds flags build.py emits natively +(reloaded via the override channel); ``extra_cmake_args`` holds user-added flags +build.py does not emit (reloaded via the extra/append channel). +``CMAKE_INSTALL_PREFIX`` is omitted from dumps (absolute build-dir path -- must +not be pinned). Repoagent/cache ``cmake_args`` are dumped for visibility but not +re-pinned on load (build.py has no per-repoagent/cache cmake-override channel); +their ``tag`` is applied. +""" + +import json +import os + +ENV_GATE = "TRITON_BUILD_EXPERIMENTAL" + +_REPO_ORG_ARG = "TRITON_REPO_ORGANIZATION" + +# Backends whose clone organization is hardcoded in build.py; a preset cannot +# change their clone URL (the -D is still pinned, harmlessly). +_FIXED_ORG_BACKENDS = frozenset(("armnn_tflite",)) + +# String values are interpolated unquoted into the generated build shell script, +# so reject characters that would let a preset file inject shell syntax. +_SHELL_UNSAFE = frozenset(";|&$`<>\n\r") + +# Environment/path-specific flags excluded from dumps -- pinning them would break +# reloading the preset into a different build directory. +_DUMP_EXCLUDE = frozenset(("CMAKE_INSTALL_PREFIX",)) + +_SECTION_KEYS = ("core", "backends", "repoagents", "caches") + +# cmake flag name -> the build.py CLI option (argparse dest) that controls it. +# Used only for provenance labeling in dump(); flags not listed are treated as +# build.py-computed defaults/derivations (source "default") unless they came +# through an explicit --extra/override-*-cmake-arg. +_FLAG_TO_DEST = { + "CMAKE_BUILD_TYPE": "build_type", + "TRITON_VERSION": "version", + "TRITON_REPO_ORGANIZATION": "github_organization", + "TRITON_COMMON_REPO_TAG": "repo_tag", + "TRITON_CORE_REPO_TAG": "repo_tag", + "TRITON_BACKEND_REPO_TAG": "repo_tag", + "TRITON_THIRD_PARTY_REPO_TAG": "repo_tag", + "TRITON_ENABLE_LOGGING": "enable_logging", + "TRITON_ENABLE_STATS": "enable_stats", + "TRITON_ENABLE_METRICS": "enable_metrics", + "TRITON_ENABLE_METRICS_GPU": "enable_gpu_metrics", + "TRITON_ENABLE_METRICS_CPU": "enable_cpu_metrics", + "TRITON_ENABLE_TRACING": "enable_tracing", + "TRITON_ENABLE_NVTX": "enable_nvtx", + "TRITON_ENABLE_GPU": "enable_gpu", + "TRITON_MIN_COMPUTE_CAPABILITY": "min_compute_capability", + "TRITON_ENABLE_MALI_GPU": "enable_mali_gpu", + "TRITON_ENABLE_GRPC": "endpoint", + "TRITON_ENABLE_HTTP": "endpoint", + "TRITON_ENABLE_SAGEMAKER": "endpoint", + "TRITON_ENABLE_VERTEX_AI": "endpoint", + "TRITON_ENABLE_GCS": "filesystem", + "TRITON_ENABLE_S3": "filesystem", + "TRITON_ENABLE_AZURE_STORAGE": "filesystem", + "TRITON_ENABLE_ENSEMBLE": "backend", + "TRITON_ENABLE_TENSORRT": "backend", + "PYBIND11_PYTHON_VERSION": "rhel_py_version", + "TRITON_PYTORCH_DOCKER_IMAGE": "image", + "TRITON_BUILD_CONTAINER": "image", + "TRITON_BUILD_ONNXRUNTIME_VERSION": "ort_version", + "TRITON_BUILD_ONNXRUNTIME_OPENVINO_VERSION": "ort_openvino_version", + "TRITON_BUILD_OPENVINO_VERSION": "standalone_openvino_version", + "TRITON_BUILD_CONTAINER_VERSION": "upstream_container_version", +} + + +class BuildPresetError(Exception): + """Raised for any problem loading, validating, or applying a build preset. + + build.py catches this and reports it via its own fail() so the user sees a + clean 'error: ...' message instead of a traceback. + """ + + +# --------------------------------------------------------------------------- # +# Shared helpers +# --------------------------------------------------------------------------- # +def explicit_cli_tags(cli_specs): + # A ":" spec means the tag was set explicitly on the command line + # (and so wins over the file). An empty tag ("name:") is treated as unset. + explicit = set() + for spec in cli_specs or []: + parts = spec.split(":", 1) + if len(parts) == 2 and parts[1] != "": + explicit.add(parts[0]) + return explicit + + +def provided_options(parser, argv): + """Return the set of argparse dests whose option strings actually appear on + the command line (argv). Lets dump() label a flag's value as coming from the + CLI even when it equals build.py's default.""" + opt_to_dest = {} + for action in parser._actions: + for opt in action.option_strings: + opt_to_dest[opt] = action.dest + provided = set() + for tok in argv[1:] if argv else []: + key = tok.split("=", 1)[0] + if key in opt_to_dest: + provided.add(opt_to_dest[key]) + return provided + + +def parse_cmake_defs(arg_list): + """Parse a list of cmake arguments (as produced by build.py's *_cmake_args + helpers) into an ordered {name: value} dict, dropping non -D entries and the + environment/path-specific excludes.""" + defs = {} + for raw in arg_list: + s = str(raw).strip().strip('"') + if not s.startswith("-D"): + continue + body = s[2:] + if "=" not in body: + continue + lhs, value = body.split("=", 1) + name = lhs.split(":", 1)[0] + if name in _DUMP_EXCLUDE: + continue + defs[name] = value + return defs + + +def flag_source(flag, *, provided_dests, is_cli=False, is_preset=False): + """Classify the provenance of a resolved cmake flag.""" + if is_preset: + return "preset" + if is_cli: + return "cli" + dest = _FLAG_TO_DEST.get(flag) + if dest is not None and dest in provided_dests: + return "cli" + return "default" + + +def _coerce_cmake_value(val): + # JSON booleans map to CMake ON/OFF; everything else stringifies. + if isinstance(val, bool): + return "ON" if val else "OFF" + return str(val) + + +def _reject_unsafe(where, value): + bad = _SHELL_UNSAFE.intersection(value) + if bad: + raise BuildPresetError( + "--build-presets-file: {} contains unsupported character(s) " + "{}".format(where, "".join(sorted(bad))) + ) + + +def _load(path): + try: + with open(path) as f: + return json.load(f) + except OSError as e: + raise BuildPresetError( + "--build-presets-file: cannot read '{}': {}".format(path, e) + ) + except json.JSONDecodeError as e: + raise BuildPresetError( + "--build-presets-file: invalid JSON in '{}': {}".format(path, e) + ) + + +# --------------------------------------------------------------------------- # +# Validation +# --------------------------------------------------------------------------- # +def _read_scalar(raw, where, *, allow_bool_num=False): + """Return the plain value from a scalar or a {"value", "source"} object, + validating it. ``where`` is a human label for error messages.""" + if isinstance(raw, dict): + if "value" not in raw: + raise BuildPresetError( + "--build-presets-file: {} object must contain 'value'".format(where) + ) + unknown = set(raw.keys()) - {"value", "source"} + if unknown: + raise BuildPresetError( + "--build-presets-file: {} has unknown key(s): {}".format( + where, ", ".join(sorted(unknown)) + ) + ) + value = raw["value"] + else: + value = raw + + if value is None or isinstance(value, (dict, list)): + raise BuildPresetError( + "--build-presets-file: {} must be a non-null scalar".format(where) + ) + if not allow_bool_num and not isinstance(value, str): + raise BuildPresetError( + "--build-presets-file: {} must be a string".format(where) + ) + return value + + +def _validate_cmake_args(cmake_args, where): + if not isinstance(cmake_args, dict): + raise BuildPresetError( + "--build-presets-file: {} must be an object".format(where) + ) + for flag, raw in cmake_args.items(): + value = _read_scalar(raw, "{}['{}']".format(where, flag), allow_bool_num=True) + _reject_unsafe("{}['{}']".format(where, flag), _coerce_cmake_value(value)) + + +def _validate_component(name, entry, kind, allow_extra): + if not isinstance(entry, dict): + raise BuildPresetError( + "--build-presets-file: {} '{}' must be an object".format(kind, name) + ) + allowed = {"tag", "cmake_args"} + if allow_extra: + allowed |= {"extra_cmake_args", "library_path"} + unknown = set(entry.keys()) - allowed + if unknown: + raise BuildPresetError( + "--build-presets-file: {} '{}' has unknown key(s): {} (allowed: " + "{})".format( + kind, name, ", ".join(sorted(unknown)), ", ".join(sorted(allowed)) + ) + ) + if "tag" in entry: + tag = _read_scalar(entry["tag"], "{} '{}' tag".format(kind, name)) + if tag.strip() == "": + raise BuildPresetError( + "--build-presets-file: {} '{}' tag must not be empty".format(kind, name) + ) + _reject_unsafe("{} '{}' tag".format(kind, name), tag) + if "library_path" in entry: + lp = _read_scalar( + entry["library_path"], "{} '{}' library_path".format(kind, name) + ) + _reject_unsafe("{} '{}' library_path".format(kind, name), lp) + for key in ("cmake_args", "extra_cmake_args"): + if key in entry: + _validate_cmake_args(entry[key], "{} '{}' {}".format(kind, name, key)) + + +def _validate(data, build_backends, build_repoagents, build_caches): + if not isinstance(data, dict): + raise BuildPresetError("--build-presets-file: top-level JSON must be an object") + + unknown_top = set(data.keys()) - (set(_SECTION_KEYS) | {"_legend"}) + if unknown_top: + raise BuildPresetError( + "--build-presets-file: unknown top-level key(s): {} (allowed: {})".format( + ", ".join(sorted(unknown_top)), + ", ".join(sorted(_SECTION_KEYS) + ["_legend"]), + ) + ) + + core = data.get("core") + if core is not None: + if not isinstance(core, dict) or ( + set(core.keys()) - {"cmake_args", "extra_cmake_args"} + ): + raise BuildPresetError( + "--build-presets-file: 'core' must be an object with only " + "'cmake_args' and/or 'extra_cmake_args'" + ) + for key in ("cmake_args", "extra_cmake_args"): + if key in core: + _validate_cmake_args(core[key], "core {}".format(key)) + + for section, valid_names, kind, allow_extra in ( + ("backends", build_backends, "backend", True), + ("repoagents", build_repoagents, "repoagent", False), + ("caches", build_caches, "cache", False), + ): + block = data.get(section) + if block is None: + continue + if not isinstance(block, dict): + raise BuildPresetError( + "--build-presets-file: '{}' must be an object".format(section) + ) + for name, entry in block.items(): + if name not in valid_names: + raise BuildPresetError( + "--build-presets-file: {} '{}' is not included in the build " + "(add it with --{} {})".format(kind, name, kind, name) + ) + _validate_component(name, entry, kind, allow_extra) + + return data + + +# --------------------------------------------------------------------------- # +# apply() -- load a preset and pin its values +# --------------------------------------------------------------------------- # +def _apply_tag(name, entry, tags, explicit, kind, messages): + if "tag" not in entry: + return + tag = _read_scalar(entry["tag"], "{} '{}' tag".format(kind, name)) + if name in explicit: + messages.append( + " {} '{}': tag '{}' from file ignored (CLI tag wins)".format( + kind, name, tag + ) + ) + else: + tags[name] = tag + messages.append(" {} '{}': tag -> {}".format(kind, name, tag)) + + +def _pin_into(bucket, cli_names, cmake_args, label, messages): + # setdefault semantics: an explicit CLI value for the same flag wins. + for flag, raw in cmake_args.items(): + value = _coerce_cmake_value(_read_scalar(raw, label, allow_bool_num=True)) + if flag in cli_names: + messages.append( + " {}: cmake '{}' from file ignored (CLI wins)".format(label, flag) + ) + continue + bucket[flag] = value + messages.append(" {}: cmake -D{}={}".format(label, flag, value)) + + +def apply( + path, + *, + backends, + repoagents, + caches, + cli_backend_specs, + cli_repoagent_specs, + cli_cache_specs, + library_paths, + extra_backend_flags, + override_backend_flags, + extra_core_flags, + override_core_flags, + backend_org_overrides, +): + """Load, validate, and apply a build preset from ``path`` into build.py's + structures (all mutated in place). Every flag in the file is pinned so the + build is reproduced; explicit command-line flags win. + + ``cmake_args`` route to the override channel (flags build.py emits natively); + ``extra_cmake_args`` route to the extra/append channel (user-added flags). + + Returns human-readable log lines. Raises BuildPresetError on any problem. + """ + if os.getenv(ENV_GATE) != "1": + raise BuildPresetError( + "--build-presets-file is experimental; set {}=1 to enable it".format( + ENV_GATE + ) + ) + + data = _validate(_load(path), backends, repoagents, caches) + explicit_be = explicit_cli_tags(cli_backend_specs) + explicit_ra = explicit_cli_tags(cli_repoagent_specs) + explicit_ca = explicit_cli_tags(cli_cache_specs) + + messages = ["applying experimental --build-presets-file from '{}'".format(path)] + + # core: cmake_args -> override channel, extra_cmake_args -> extra channel. + core = data.get("core", {}) + _pin_into( + override_core_flags, + set(override_core_flags), + core.get("cmake_args", {}), + "core", + messages, + ) + _pin_into( + extra_core_flags, + set(extra_core_flags), + core.get("extra_cmake_args", {}), + "core extra", + messages, + ) + + # backends: tag + per-flag override/extra (TRITON_REPO_ORGANIZATION also + # drives the clone org) + library_path. + for be, entry in data.get("backends", {}).items(): + _apply_tag(be, entry, backends, explicit_be, "backend", messages) + + cli_names = set(extra_backend_flags.get(be, {})) | set( + override_backend_flags.get(be, {}) + ) + override_bucket = override_backend_flags.setdefault(be, {}) + for flag, raw in entry.get("cmake_args", {}).items(): + value = _coerce_cmake_value( + _read_scalar(raw, "backend '{}'".format(be), allow_bool_num=True) + ) + if flag in cli_names: + messages.append( + " backend '{}': cmake '{}' from file ignored (CLI " + "wins)".format(be, flag) + ) + continue + override_bucket[flag] = value + if flag == _REPO_ORG_ARG and be not in _FIXED_ORG_BACKENDS: + backend_org_overrides[be] = value + messages.append(" backend '{}': cmake -D{}={}".format(be, flag, value)) + + _pin_into( + extra_backend_flags.setdefault(be, {}), + cli_names, + entry.get("extra_cmake_args", {}), + "backend '{}' extra".format(be), + messages, + ) + + if "library_path" in entry: + lp = _read_scalar(entry["library_path"], "backend '{}'".format(be)) + if be in library_paths: + messages.append( + " backend '{}': library_path from file ignored (CLI " + "wins)".format(be) + ) + else: + library_paths[be] = lp + messages.append(" backend '{}': library_path -> {}".format(be, lp)) + + # repoagents / caches: pin the tag; cmake_args are visibility-only on load. + for section, tags, explicit, kind in ( + ("repoagents", repoagents, explicit_ra, "repoagent"), + ("caches", caches, explicit_ca, "cache"), + ): + for name, entry in data.get(section, {}).items(): + _apply_tag(name, entry, tags, explicit, kind, messages) + if entry.get("cmake_args"): + messages.append( + " {} '{}': cmake_args are informational and not " + "re-pinned".format(kind, name) + ) + + return messages + + +# --------------------------------------------------------------------------- # +# dump() -- write the annotated snapshot +# --------------------------------------------------------------------------- # +def _annotate(defs, provided, cli_keys, preset_keys): + return { + name: { + "value": value, + "source": flag_source( + name, + provided_dests=provided, + is_cli=name in cli_keys, + is_preset=name in preset_keys, + ), + } + for name, value in defs.items() + } + + +def _split_entry(arg_list, extra_names, provided, cli_keys, preset_keys): + # Split parsed -D flags into the override channel (built natively by build.py) + # and the extra/append channel (user-added flags), so a reload routes each + # back to the correct channel. + defs = parse_cmake_defs(arg_list) + cmake = {n: v for n, v in defs.items() if n not in extra_names} + extra = {n: v for n, v in defs.items() if n in extra_names} + entry = {"cmake_args": _annotate(cmake, provided, cli_keys, preset_keys)} + if extra: + entry["extra_cmake_args"] = _annotate(extra, provided, cli_keys, preset_keys) + return entry + + +def _tag_entry(name, tags, before, explicit): + if tags.get(name) != before.get(name): + source = "preset" + elif name in explicit: + source = "cli" + else: + source = "default" + return {"value": tags[name], "source": source} + + +def write_snapshot( + path, + *, + parser, + argv, + core_args, + backend_args, + repoagent_args, + cache_args, + backends, + repoagents, + caches, + before_backends, + before_repoagents, + before_caches, + cli_backend_specs, + cli_repoagent_specs, + cli_cache_specs, + cli_extra_be, + cli_override_be, + cli_core, + extra_backend_flags, + override_backend_flags, + extra_core_flags, + override_core_flags, + library_paths, +): + """Build the provenance-annotated snapshot from the raw per-component cmake + argument lists (as returned by build.py's *_cmake_args helpers) and write it + to ``path``. Keeps all snapshot logic out of build.py.""" + provided = provided_options(parser, argv) + exp_be = explicit_cli_tags(cli_backend_specs) + exp_ra = explicit_cli_tags(cli_repoagent_specs) + exp_ca = explicit_cli_tags(cli_cache_specs) + + core_all = set(extra_core_flags) | set(override_core_flags) + snapshot = { + "_legend": { + "source": { + "cli": "explicit command-line value", + "preset": "value from --build-presets-file", + "default": "build.py default or derived value", + } + }, + "core": _split_entry( + core_args, set(extra_core_flags), provided, cli_core, core_all - cli_core + ), + "backends": {}, + "repoagents": {}, + "caches": {}, + } + + for be, args in backend_args.items(): + cli_keys = cli_extra_be.get(be, set()) | cli_override_be.get(be, set()) + all_keys = set(extra_backend_flags.get(be, {})) | set( + override_backend_flags.get(be, {}) + ) + entry = _split_entry( + args, + set(extra_backend_flags.get(be, {})), + provided, + cli_keys, + all_keys - cli_keys, + ) + entry["tag"] = _tag_entry(be, backends, before_backends, exp_be) + if be in library_paths: + entry["library_path"] = {"value": library_paths[be], "source": "cli"} + snapshot["backends"][be] = entry + + for ra, args in repoagent_args.items(): + snapshot["repoagents"][ra] = { + "tag": _tag_entry(ra, repoagents, before_repoagents, exp_ra), + "cmake_args": _annotate(parse_cmake_defs(args), provided, set(), set()), + } + for ca, args in cache_args.items(): + snapshot["caches"][ca] = { + "tag": _tag_entry(ca, caches, before_caches, exp_ca), + "cmake_args": _annotate(parse_cmake_defs(args), provided, set(), set()), + } + + return dump(path, snapshot) + + +def dump(path, snapshot): + """Serialize a fully-built snapshot dict to ``path`` as JSON. Returns log + lines. Raises BuildPresetError if the file cannot be written.""" + try: + with open(path, "w") as f: + json.dump(snapshot, f, indent=2, sort_keys=True) + f.write("\n") + except OSError as e: + raise BuildPresetError( + "--build-presets-file dump: cannot write '{}': {}".format(path, e) + ) + return ["wrote resolved build configuration snapshot to '{}'".format(path)]