diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ec5ed63a..52ab1523 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,12 +26,12 @@ ## Validation - + - Commands run: - Skipped checks or known failures: ## Documentation and Artifacts - [ ] Docs updated, or not needed -- [ ] If docs changed: `make docs-build` passes locally -- [ ] If tutorial sources changed: notebooks regenerated with `make convert-notebooks` +- [ ] If docs changed: `mise run docs:build` passes locally +- [ ] If tutorial sources changed: notebooks regenerated with `mise run notebooks:execute` - [ ] If e2e, benchmark, or model-provider behavior changed: relevant validation is listed above diff --git a/.github/actions/setup-python-env/action.yml b/.github/actions/setup-python-env/action.yml index 767d27da..e794a751 100644 --- a/.github/actions/setup-python-env/action.yml +++ b/.github/actions/setup-python-env/action.yml @@ -13,7 +13,7 @@ # limitations under the License. name: "Setup Python Environment" -description: "Common setup for Python projects: checkout, uv, Python, and dependencies" +description: "Common setup for Python projects: checkout, mise, Python, and dependencies" inputs: ref: @@ -28,6 +28,14 @@ inputs: description: "Number of commits to fetch (0 for all history)" required: false default: "1" + checkout: + description: "Whether to checkout the repository before setting up the environment" + required: false + default: "true" + dependency-profile: + description: "Locked dependency profile to sync: runtime, dev, docs, or notebooks" + required: false + default: "dev" outputs: merge-base: @@ -38,6 +46,7 @@ runs: using: "composite" steps: - name: Checkout repository + if: inputs.checkout == 'true' uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} @@ -45,7 +54,7 @@ runs: - name: Get the merge base SHA id: get_merge_base - if: inputs.fetch-depth == '0' + if: inputs.checkout == 'true' && inputs.fetch-depth == '0' shell: bash run: | if [ -n "${{ github.base_ref }}" ]; then @@ -58,12 +67,6 @@ runs: echo "Merge Base SHA: $MERGE_BASE_SHA" echo "merge_base=$MERGE_BASE_SHA" >> $GITHUB_OUTPUT - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - # version is parsed from pyproject.toml - enable-cache: true - - name: Set up Python ${{ inputs.python-version }} if: inputs.python-version != '' uses: actions/setup-python@v6 @@ -75,3 +78,18 @@ runs: uses: actions/setup-python@v6 with: python-version-file: ".python-version" + + - name: Install mise and project tools + env: + MISE_REQUIRE_SIGNED_INSTALL: "1" + shell: bash + run: | + MISE_GPG_KEY=24853EC9F655CE80B48E6C3A8B81C9D17413A06D \ + bash tools/install-mise.sh + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" + if [ -n "${{ inputs.python-version }}" ]; then + export UV_PYTHON="${{ inputs.python-version }}" + fi + MISE_YES=1 mise run setup "${{ inputs.dependency-profile }}" --no-hooks diff --git a/.github/workflows/benchmark-ci.yml b/.github/workflows/benchmark-ci.yml index 687690ca..c112bea5 100644 --- a/.github/workflows/benchmark-ci.yml +++ b/.github/workflows/benchmark-ci.yml @@ -63,29 +63,26 @@ jobs: timeout-minutes: 120 steps: + - name: Checkout workflow revision + uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-python-env + with: + checkout: "false" + python-version: "3.11" + - name: Checkout benchmark target - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ env.BENCHMARK_REF }} fetch-depth: "0" + path: benchmark-target - name: Resolve benchmark target commit id: target + working-directory: benchmark-target run: echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - - name: Install dependencies - run: uv sync --group dev - - name: Check NVIDIA API key env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} @@ -96,6 +93,7 @@ jobs: fi - name: Run benchmark suite + working-directory: benchmark-target env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} run: | @@ -114,7 +112,7 @@ jobs: FAIL_FAST_ARGS+=(--fail-fast) fi - uv run python tools/measurement/run_benchmarks.py \ + uv run --locked python tools/measurement/run_benchmarks.py \ "$BENCHMARK_SUITE" \ --output "$BENCHMARK_OUTPUT_DIR" \ --overwrite \ @@ -124,6 +122,7 @@ jobs: - name: Add benchmark summary if: always() + working-directory: benchmark-target env: BENCHMARK_COMMIT: ${{ steps.target.outputs.commit }} run: | @@ -170,5 +169,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: anonymizer-benchmark-${{ steps.target.outputs.commit }} - path: ${{ env.BENCHMARK_OUTPUT_DIR }}/ + path: benchmark-target/${{ env.BENCHMARK_OUTPUT_DIR }}/ if-no-files-found: warn diff --git a/.github/workflows/build-notebooks.yml b/.github/workflows/build-notebooks.yml index 410cc12b..e70a4857 100644 --- a/.github/workflows/build-notebooks.yml +++ b/.github/workflows/build-notebooks.yml @@ -14,15 +14,14 @@ jobs: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} steps: - name: Checkout repository - uses: actions/checkout@v2 - - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: actions/checkout@v6 + - uses: ./.github/actions/setup-python-env with: - version: "0.9.5" - - name: Set up Python - run: uv python install 3.11 + checkout: "false" + python-version: "3.11" + dependency-profile: "notebooks" - name: Convert and execute notebooks - run: make convert-notebooks + run: mise run notebooks:execute - name: Upload notebooks as artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfb14f1e..486a034b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,16 @@ env: DEFAULT_PYTHON_VERSION: "3.11" jobs: + benchmark-task-shell: + name: Benchmark task (macOS Bash 3.2) + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Test benchmark task + run: bash tests/tools/test_benchmark_task.sh + test: name: Test runs-on: ubuntu-latest @@ -32,14 +42,12 @@ jobs: - uses: ./.github/actions/setup-python-env with: + checkout: "false" fetch-depth: "0" python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: uv sync --group dev - - name: Run tests with coverage - run: make coverage + run: mise run test:coverage check: name: Check @@ -53,20 +61,21 @@ jobs: - uses: ./.github/actions/setup-python-env with: + checkout: "false" fetch-depth: "0" python-version: ${{ env.DEFAULT_PYTHON_VERSION }} - - name: Install dependencies - run: uv sync --group dev + - name: Check formatting + run: mise run check:format - - name: Check format and lint - run: make format-check + - name: Check lint rules + run: mise run check:lint - name: Run type checks - run: make typecheck + run: mise run check:type - name: Check uv.lock is up to date - run: make lock-check + run: mise run check:lock - name: Check copyright headers - run: make copyright-check + run: mise run check:license:headers diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1a8338ca..b06c7f91 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,6 +11,10 @@ on: - "mkdocs.yml" - "src/**" - ".github/workflows/docs.yml" + - ".github/actions/setup-python-env/action.yml" + - ".mise.toml" + - "mise.lock" + - ".mise/tasks/**" pull_request: branches: [main] paths: @@ -18,6 +22,10 @@ on: - "mkdocs.yml" - "src/**" - ".github/workflows/docs.yml" + - ".github/actions/setup-python-env/action.yml" + - ".mise.toml" + - "mise.lock" + - ".mise/tasks/**" workflow_dispatch: release: types: [published] @@ -48,10 +56,9 @@ jobs: - uses: ./.github/actions/setup-python-env with: + checkout: "false" fetch-depth: "0" - - - name: Install docs dependencies - run: uv sync --group dev --group docs + dependency-profile: "docs" - name: Configure git for mike run: | @@ -59,11 +66,11 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" - name: Build docs - run: make docs-build + run: mise run docs:build - name: Deploy docs with mike if: github.event_name == 'push' - run: uv run --group docs mike deploy --push --update-aliases dev + run: uv run --locked --group docs mike deploy --push --update-aliases dev deploy-release: if: github.event_name == 'release' @@ -78,10 +85,9 @@ jobs: - uses: ./.github/actions/setup-python-env with: + checkout: "false" fetch-depth: "0" - - - name: Install docs dependencies - run: uv sync --group dev --group docs + dependency-profile: "docs" - name: Download notebook artifacts uses: actions/download-artifact@v5 @@ -95,9 +101,9 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" - name: Build docs - run: make docs-build + run: mise run docs:build - name: Deploy release docs with mike run: | VERSION=$(echo "${{ github.event.release.tag_name }}" | sed 's/^v//' | sed 's/ .*$//') - uv run --group docs mike deploy --push --update-aliases "$VERSION" latest + uv run --locked --group docs mike deploy --push --update-aliases "$VERSION" latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9266f426..61c2e546 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,20 +51,15 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - - - name: Set up Python - uses: actions/setup-python@v6 + - uses: ./.github/actions/setup-python-env with: - python-version-file: ".python-version" + checkout: "false" + dependency-profile: "runtime" - name: Build wheel id: build run: | - make build-wheel + mise run build:wheel WHEEL=$(ls dist/*.whl) VERSION=$(echo "$WHEEL" | sed -n 's/.*-\([0-9][^-]*\)-.*/\1/p') echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -149,11 +144,10 @@ jobs: - uses: ./.github/actions/setup-python-env with: + checkout: "false" ref: ${{ inputs.release-ref }} fetch-depth: "0" - - - name: Install docs dependencies - run: uv sync --group dev --group docs + dependency-profile: "docs" - name: Configure git for mike run: | @@ -161,9 +155,9 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" - name: Build docs - run: make docs-build + run: mise run docs:build - name: Deploy release docs with mike env: VERSION: ${{ needs.publish-wheel.outputs.version }} - run: uv run --group docs mike deploy --push --update-aliases "$VERSION" latest + run: uv run --locked --group docs mike deploy --push --update-aliases "$VERSION" latest diff --git a/.gitignore b/.gitignore index a5bc84b5..d76c28d4 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,13 @@ ipython_config.py .uv/ uv.lock.bak +# mise local overrides +.mise.local.toml +mise.local.toml +mise.*.local.toml +!.mise/tasks/build/ +!.mise/tasks/build/** + # Environments .env .env.* diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..f5bbed2c --- /dev/null +++ b/.mise.toml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Tool versions managed by mise (https://mise.jdx.dev/). +# Run `mise install` to install all tools. +min_version = "2026.7.6" + +[tools] +uv = "0.12.3" +ruff = "0.16.2" +ty = "0.0.69" + +[env] +_.file = [".env", { path = ".env.local", redact = true }] + +[task_config] +includes = [".mise/tasks", ".mise/tasks/*.toml"] + +[settings] +lockfile = true +install_before = "7d" +lockfile_platforms = ["macos-arm64", "linux-x64", "linux-arm64"] +idiomatic_version_file_enable_tools = [] +python.uv_venv_auto = "source" diff --git a/.mise/tasks/benchmark b/.mise/tasks/benchmark new file mode 100755 index 00000000..682d0d24 --- /dev/null +++ b/.mise/tasks/benchmark @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +#MISE description="Run a benchmark profile and write results under benchmark-runs/ or BENCHMARK_OUTPUT_DIR. May require provider credentials." +#USAGE arg "" help="Benchmark profile: smoke or smoke-traces" { +#USAGE choices "smoke" "smoke-traces" +#USAGE } +#USAGE arg "[runner_args]" var=#true double_dash="optional" help="Arguments forwarded to the benchmark runner" + +set -euo pipefail + +profile="${usage_profile?}" +suite="tools/measurement/examples/repo-data-smoke.yaml" +output_dir="${BENCHMARK_OUTPUT_DIR:-benchmark-runs/${profile}}" +runner_args=() +if [[ -n "${usage_runner_args:-}" ]]; then + eval "runner_args=($usage_runner_args)" +fi + +case "$profile" in + smoke) + trace_args=() + ;; + smoke-traces) + trace_args=(--dd-trace last_message --dd-task-trace) + ;; +esac + +uv run --locked --group dev python tools/measurement/run_benchmarks.py \ + "$suite" \ + --output "$output_dir" \ + ${trace_args[@]+"${trace_args[@]}"} \ + ${runner_args[@]+"${runner_args[@]}"} diff --git a/.mise/tasks/build/wheel b/.mise/tasks/build/wheel new file mode 100755 index 00000000..dc353f3b --- /dev/null +++ b/.mise/tasks/build/wheel @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +#MISE description="Build the wheel into dist/ using the version derived from git tags. Replaces the existing dist/ directory." + +set -euo pipefail + +rm -rf dist/ +uv build --wheel diff --git a/.mise/tasks/clean/_default b/.mise/tasks/clean/_default new file mode 100755 index 00000000..a2c6fbf3 --- /dev/null +++ b/.mise/tasks/clean/_default @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +#MISE description="Delete htmlcov/, .coverage files, .pytest_cache/, Python bytecode, and Python cache directories." +#MISE depends=["clean:pycache"] + +set -euo pipefail + +rm -rf htmlcov .coverage .coverage.* .pytest_cache diff --git a/.mise/tasks/clean/branches b/.mise/tasks/clean/branches new file mode 100755 index 00000000..5002496f --- /dev/null +++ b/.mise/tasks/clean/branches @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +#MISE description="Prune remote refs and delete local branches already merged into origin/main without switching branches. Deleted branches are recoverable through Git reflogs." + +set -euo pipefail + +git fetch --prune +current_branch="$(git branch --show-current)" +while IFS= read -r branch; do + if [[ "$branch" == "main" || "$branch" == "$current_branch" ]]; then + continue + fi + git branch -d -- "$branch" || true +done < <(git branch --format='%(refname:short)' --merged origin/main) diff --git a/.mise/tasks/clean/pycache b/.mise/tasks/clean/pycache new file mode 100755 index 00000000..a477a29d --- /dev/null +++ b/.mise/tasks/clean/pycache @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +#MISE description="Delete Python __pycache__ directories and .pyc files within the repository." + +set -euo pipefail + +find . -type d -name __pycache__ -exec rm -rf {} + +find . -type f -name "*.pyc" -delete diff --git a/.mise/tasks/docs.toml b/.mise/tasks/docs.toml new file mode 100644 index 00000000..ea669a41 --- /dev/null +++ b/.mise/tasks/docs.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +["docs:serve"] +description = "Serve the MkDocs site locally at http://127.0.0.1:8000 and watch source files." +run = "uv run --locked --group docs mkdocs serve" + +["docs:build"] +description = "Build the documentation site in strict mode. Writes the ignored site/ directory." +run = "uv run --locked --group docs mkdocs build --strict" diff --git a/.mise/tasks/notebooks/execute b/.mise/tasks/notebooks/execute new file mode 100755 index 00000000..d634d5db --- /dev/null +++ b/.mise/tasks/notebooks/execute @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +#MISE description="Execute tutorial sources and replace generated files in docs/notebooks/. May require model-provider credentials." + +set -euo pipefail + +mkdir -p docs/notebooks +uv run --locked --group notebooks python -m ipykernel install --user --name anonymizer-venv +uv run --locked --group notebooks jupytext --to ipynb --set-kernel anonymizer-venv --execute docs/notebook_source/*.py +mv docs/notebook_source/*.ipynb docs/notebooks/ diff --git a/.mise/tasks/publish.toml b/.mise/tasks/publish.toml new file mode 100644 index 00000000..0d6dd56f --- /dev/null +++ b/.mise/tasks/publish.toml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +["publish:pypi"] +description = "Build and publish an explicit version to PyPI. Requires TWINE_USERNAME and TWINE_PASSWORD; --dry-run performs no upload." +usage = ''' +arg "" help="Expected Python package version" +flag "--dry-run" help="Build and validate without uploading" +''' +run = [ + ''' +if [ "${usage_dry_run:-false}" != "true" ]; then + : "${TWINE_USERNAME:?TWINE_USERNAME is required}" + : "${TWINE_PASSWORD:?TWINE_PASSWORD is required}" +fi +''', + { task = "build:wheel" }, + ''' +wheel="$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit)" +case "$(basename "$wheel")" in + *"-${usage_version?}-"*) ;; + *) + echo "Built wheel does not match requested version ${usage_version?}: ${wheel:-none}" >&2 + exit 1 + ;; +esac + +echo "Destination: PyPI" +echo "Version: ${usage_version?}" +if [ "${usage_dry_run:-false}" = "true" ]; then + echo "Dry run: upload skipped" + exit 0 +fi + +uvx twine upload --non-interactive "$wheel" +''', +] diff --git a/.mise/tasks/quality.toml b/.mise/tasks/quality.toml new file mode 100644 index 00000000..6119d76a --- /dev/null +++ b/.mise/tasks/quality.toml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[format] +description = "Format and safely fix tracked Python files and rendered notebooks. Modifies files in place." +run = "tools/codestyle/format.sh" + +["check:format"] +description = "Check formatting of tracked Python files and rendered notebooks without modifying them." +run = "tools/codestyle/format.sh --check" + +["check:lint"] +description = "Check Ruff rules for tracked Python files and rendered notebooks without modifying them." +run = "tools/codestyle/ruff_check.sh" + +["check:type"] +description = "Run blocking ty checks across source, tests, tools, docs, and rendered notebooks." +run = "uv run --locked --group docs tools/codestyle/typecheck.sh" + +["license:headers:fix"] +description = "Add missing SPDX headers to repository files. Modifies files in place." +run = "uv run tools/codestyle/copyright_fixer.py ." + +["check:license:headers"] +description = "Check SPDX headers without modifying files." +run = "uv run tools/codestyle/copyright_fixer.py --check ." + +["check:lock"] +description = "Verify that uv.lock matches pyproject.toml." +run = "uv lock --check" + +["lock:update"] +description = "Regenerate uv.lock from project dependency metadata. Modifies uv.lock." +run = "uv lock" + +[check] +description = "Run the repository read-only quality checks." +depends = ["check:format", "check:lint", "check:type", "check:lock", "check:license:headers"] diff --git a/.mise/tasks/setup.toml b/.mise/tasks/setup.toml new file mode 100644 index 00000000..22309607 --- /dev/null +++ b/.mise/tasks/setup.toml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[setup] +description = "Install pinned tools and sync a dependency profile. Developer profiles also install repository hooks; modifies the Mise cache, .venv, and .git/hooks." +usage = ''' +arg "[profile]" help="Dependency profile to install" default="dev" { + choices "runtime" "dev" "docs" "notebooks" "all" +} +flag "--no-hooks" help="Skip hook installation in ephemeral CI environments" +''' +run = [ + "MISE_YES=1 mise trust", + "MISE_YES=1 mise install", + { task = "deps:sync", args = ["{{usage.profile}}"] }, + ''' +if [ "${usage_profile?}" != "runtime" ] && [ "${usage_no_hooks:-false}" != "true" ]; then + mise run hooks:install +fi +''', +] + +["deps:sync"] +description = "Synchronize one locked dependency profile into .venv or UV_PROJECT_ENVIRONMENT." +usage = ''' +arg "[profile]" help="Dependency profile to install" default="dev" { + choices "runtime" "dev" "docs" "notebooks" "all" +} +''' +run = ''' +case "${usage_profile:-dev}" in + runtime) uv sync --locked --no-default-groups ;; + dev) uv sync --locked --group dev ;; + docs) uv sync --locked --group dev --group docs ;; + notebooks) uv sync --locked --group dev --group notebooks ;; + all) uv sync --locked --all-groups ;; +esac +''' + +["hooks:install"] +description = "Install the repository-managed pre-commit and commit-message hooks into .git/hooks." +run = "uv run --locked --group dev pre-commit install" diff --git a/.mise/tasks/tests.toml b/.mise/tasks/tests.toml new file mode 100644 index 00000000..66eb6df6 --- /dev/null +++ b/.mise/tasks/tests.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[test] +description = "Run all unit tests." +run = [ + "bash tests/tools/test_benchmark_task.sh", + "uv run --locked --group dev pytest", +] + +["test:all"] +description = "Run unit and opt-in end-to-end tests. May require model-provider credentials." +run = [ + { task = "test" }, + { task = "test:e2e" }, +] + +["test:e2e"] +description = "Run the opt-in end-to-end test suite. May require model-provider credentials." +run = "uv run --locked --group dev pytest -m e2e tests_e2e/test_e2e.py" + +["test:coverage"] +description = "Run unit tests and write terminal, .coverage, and htmlcov coverage reports." +run = [ + "bash tests/tools/test_benchmark_task.sh", + "uv run --locked --group dev pytest --cov=anonymizer --cov-report=term-missing --cov-report=html", +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4329c4ac..99705c75 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: - id: format name: Format language: system - entry: uv run tools/codestyle/format.sh + entry: uv run --locked tools/codestyle/format.sh types: [python] pass_filenames: true @@ -34,18 +34,10 @@ repos: types_or: [python, shell] pass_filenames: true - - id: uv-lock - name: uv lock check - language: system - entry: bash -c 'uv lock && git diff --exit-code uv.lock' - files: pyproject.toml - stages: [pre-commit] - pass_filenames: false - - id: check name: Check language: system - entry: make check + entry: mise run check pass_filenames: false stages: [pre-commit] diff --git a/AGENTS.md b/AGENTS.md index f71af905..59180f53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,12 +113,14 @@ One pipeline-specific fact worth knowing: `COL_TEXT` is the internal name for th ## Development ```bash -make test # run all tests -make bootstrap # install dev dependencies -make format # ruff format + sort imports -make format-check # read-only lint check (used in CI) -make typecheck # blocking ty type check (warnings fail too) -make docs-serve # local MkDocs server at http://127.0.0.1:8000 +mise run setup # install pinned tools, dev dependencies, and hooks +mise run setup all # install pinned tools, every dependency group, and hooks +mise run test # run all unit tests +mise run test:all # run unit and opt-in end-to-end tests +mise run format # format and safely fix Python files and notebooks +mise run check # run all read-only static checks +mise run check ::: test # run static checks and unit tests before opening a PR +mise run docs:serve # local MkDocs server at http://127.0.0.1:8000 ``` For contributor workflow and branch naming see [CONTRIBUTING.md](CONTRIBUTING.md). For local setup, tests, docs, and day-to-day development tasks see [DEVELOPMENT.md](DEVELOPMENT.md). For code style and naming conventions see [STYLEGUIDE.md](STYLEGUIDE.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6900834..2a09d5bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ This document covers contribution policy and pull request expectations. For loca 3. Create a branch from the latest `main` using the [branch naming](#branch-naming) convention. 4. For non-trivial changes, write a plan document before implementation. See [Agent-Assisted Development](#agent-assisted-development). 5. Implement the change following [AGENTS.md](AGENTS.md), [STYLEGUIDE.md](STYLEGUIDE.md), and [DEVELOPMENT.md](DEVELOPMENT.md). -6. Install the repository hooks once per clone with `make install-pre-commit`. +6. `mise run setup` installs the repository hooks. Reinstall them with `mise run hooks:install` when needed. 7. Run the relevant tests from [DEVELOPMENT.md](DEVELOPMENT.md#validation-before-opening-a-pr). 8. Commit with DCO signoff. The pre-commit hooks run the repository checks before accepting the commit. 9. Open a pull request with a conventional title, a linked issue, and a completed checklist. @@ -155,16 +155,17 @@ The `main` branch has these protections: Install the repository hooks once per clone: ```bash -make install-pre-commit +mise run hooks:install ``` Before Git records a commit, the hooks check file hygiene, format and lint staged Python files, repair SPDX headers, -verify `uv.lock` when `pyproject.toml` changes, and run `make check`. The aggregate check covers four read-only stages: +verify `uv.lock` when `pyproject.toml` changes, and run `mise run check`. The aggregate check covers five read-only stages: -- `make format-check` -- `make typecheck` -- `make lock-check` -- `make copyright-check` +- `mise run check:format` +- `mise run check:lint` +- `mise run check:type` +- `mise run check:lock` +- `mise run check:license:headers` The commit-message hook rejects commits without a `Signed-off-by` line. If a hook changes a file, review the change, stage it again, and retry the commit. Do not bypass repository hooks with `git commit --no-verify`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ece79800..ccd79e21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,6 +6,7 @@ This guide covers local setup, common development commands, testing, documentati - Python 3.11+ - Git +- [mise](https://mise.jdx.dev/) for pinned development tools and task execution - [uv](https://docs.astral.sh/uv/) for dependency management - [gh](https://cli.github.com/) for optional GitHub CLI workflows @@ -18,20 +19,24 @@ Clone the repository and install development dependencies: ```bash git clone https://github.com//Anonymizer.git cd Anonymizer -make bootstrap +make setup ``` -Install docs or notebook dependencies when needed: +`make setup` installs Mise when needed, then runs the default `dev` setup profile. If Mise is already installed, run the +same onboarding flow directly: ```bash -make install-dev-docs -make install-dev-notebooks +mise run setup ``` -Install pre-commit hooks once after cloning: +The default profile installs pinned tools, development dependencies, and repository hooks. Select another profile during +onboarding, or synchronize one profile later: ```bash -make install-pre-commit +mise run setup docs +mise run setup notebooks +mise run deps:sync docs +mise run deps:sync notebooks ``` If you work from a fork, add the upstream remote: @@ -50,67 +55,85 @@ git pull --ff-only origin main # use upstream main when origin is your fork git checkout -b //- ``` -Common Makefile targets: +Common mise tasks: ```bash -make bootstrap # install dev dependencies -make install-dev-docs # install dev + docs dependencies -make install-dev-notebooks # install dev + notebook dependencies -make install-pre-commit # install pre-commit hooks -make check # read-only format, lint, typecheck, lock, SPDX checks -make test # unit tests -make coverage # unit tests with coverage report -make docs-build # strict docs build -make docs-serve # local docs server -make convert-notebooks # regenerate tutorial notebooks +mise run setup # tools, dev dependencies, and repository hooks +mise run setup all # tools, every dependency group, and repository hooks +mise run deps:sync docs # synchronize dev + docs dependencies +mise run deps:sync notebooks # synchronize dev + notebook dependencies +mise run hooks:install # reinstall repository hooks +mise run check # read-only format, lint, type, lock, and SPDX checks +mise run check ::: test # read-only checks plus unit tests +mise run test # unit tests +mise run test:all # unit and opt-in end-to-end tests (credentials may be required) +mise run test:coverage # unit tests with coverage report +mise run docs:build # strict docs build +mise run docs:serve # local docs server +mise run notebooks:execute # execute sources and replace generated notebooks ``` +`setup` and `deps:sync` use `uv sync --locked` and fail when `uv.lock` does not match the project metadata. After changing +dependencies in `pyproject.toml`, run `mise run lock:update`, review the lockfile diff, then rerun the required setup or +dependency profile. + +Task names follow `[:[:...]]`. Colons separate concepts; public task names do not use hyphens +or underscores. Run `mise tasks` for the complete tree. Commands containing `check` leave tracked files unchanged. + +The Makefile only exposes `help`, `install-mise`, and `setup`. Developer commands belong in `.mise/tasks/`. + ## Validation Before Opening a PR Run the smallest useful check while iterating, then run the full relevant set before requesting review. -For most code changes: +For most code changes, run the local pre-PR gate: ```bash -make check -make test +mise run check ::: test ``` For changes that affect coverage-sensitive code: ```bash -make coverage +mise run test:coverage ``` For end-to-end behavior: ```bash -make test-e2e +mise run test:e2e +``` + +To run both the unit and end-to-end suites: + +```bash +mise run test:all ``` For docs changes: ```bash -make install-dev-docs -make docs-build +mise run docs:build ``` For tutorial source changes: ```bash -make install-dev-notebooks -make convert-notebooks -make docs-build +mise run notebooks:execute +mise run docs:build ``` -`make convert-notebooks` executes `docs/notebook_source/*.py` and writes generated notebooks to `docs/notebooks/`. Review the generated notebook diffs before committing them. +`mise run notebooks:execute` executes `docs/notebook_source/*.py` and replaces generated notebooks in `docs/notebooks/`. +It may require model-provider credentials. Review the generated notebook diffs before committing them. +Tasks that use `uv run --locked --group ` synchronize that locked profile before running. Use `deps:sync` when +you want to prepare a profile without running another task. ## Testing Run all unit tests: ```bash -make test +mise run test ``` Run a specific test file: @@ -128,7 +151,7 @@ uv run --group dev pytest tests/engine/test_detection_workflow.py::test_name Run coverage: ```bash -make coverage +mise run test:coverage ``` Testing expectations: @@ -144,23 +167,27 @@ Testing expectations: Format and lint: ```bash -make format -make format-check +mise run format +mise run check:format +mise run check:lint ``` +Ruff formats and lints tracked Python files and rendered notebooks. The ty configuration includes `docs`, so the +blocking type check also checks code cells in `docs/notebooks/*.ipynb`. + Run all read-only checks: ```bash -make check +mise run check ``` -`make check` runs `make format-check`, `make typecheck`, `make lock-check`, and `make copyright-check`. CI runs the -same four Make targets as separate steps in its `Check` job so failures identify the affected stage. +`mise run check` runs `mise run check:format`, `mise run check:lint`, `mise run check:type`, `mise run check:lock`, and +`mise run check:license:headers`. CI runs the same tasks as separate steps so failures identify the affected stage. Run the blocking type checker: ```bash -make typecheck +mise run check:type ``` `ty` checks `src`, `tests`, `tests_e2e`, `docs`, `scripts`, and `tools`. Errors and warnings fail locally and in CI. @@ -168,14 +195,20 @@ make typecheck Check lockfile freshness: ```bash -make lock-check +mise run check:lock +``` + +Regenerate the lockfile after an intentional dependency change: + +```bash +mise run lock:update ``` Check or repair SPDX headers: ```bash -make copyright-check -make copyright +mise run check:license:headers +mise run license:headers:fix ``` ## Pre-Commit Hooks @@ -183,15 +216,16 @@ make copyright Install hooks once: ```bash -make install-pre-commit +mise run hooks:install ``` -Before Git records a commit, the hooks check file hygiene, format and lint staged Python files, repair SPDX headers, -verify `uv.lock` when `pyproject.toml` changes, and run the repository-wide `make check`. The commit-message hook -rejects commits without a DCO `Signed-off-by` line. +Before Git records a commit, the hooks check file hygiene, format and lint staged Python files, repair SPDX headers, and +run the repository-wide `mise run check`. That aggregate includes the blocking ty check and read-only lock verification. +The commit-message hook rejects commits without a DCO `Signed-off-by` line. -If a hook changes a file, review the change, stage it again, and retry the commit. In particular, the uv-lock hook may -regenerate `uv.lock` when `pyproject.toml` changes. Do not bypass repository hooks with `git commit --no-verify`. +If a hook changes a file, review the change, stage it again, and retry the commit. If lock verification fails after a +dependency change, run `mise run lock:update` and review the result. Do not bypass repository hooks with +`git commit --no-verify`. ## Secrets and Credentials @@ -208,13 +242,13 @@ repository history before sharing the branch further. Serve docs locally: ```bash -make docs-serve +mise run docs:serve ``` Build docs in strict mode: ```bash -make docs-build +mise run docs:build ``` Update docs when a change affects public API behavior, CLI behavior, examples, notebooks, configuration, contributor workflow, or release process. @@ -229,7 +263,7 @@ Tutorial notebooks are generated from Python sources: When editing tutorial sources, regenerate notebooks with: ```bash -make convert-notebooks +mise run notebooks:execute ``` Notebook execution can require configured model provider credentials. If notebooks cannot be regenerated locally, state @@ -241,7 +275,7 @@ generated notebooks. Build a wheel locally: ```bash -make build-wheel +mise run build:wheel ``` Release tags use `vMAJOR.MINOR.PATCH` for stable releases and `vMAJOR.MINOR.PATCHrcN` for release candidates, while the Python package version is the unprefixed version. diff --git a/Makefile b/Makefile index 4b117e0d..0d20c23f 100644 --- a/Makefile +++ b/Makefile @@ -1,150 +1,23 @@ -help: - @echo "" - @echo "Anonymizer Makefile" - @echo "===================" - @echo "" - @echo " bootstrap - Install Python dependencies (dev group)" - @echo " install - Install project dependencies with uv" - @echo " install-dev - Install project with dev dependencies" - @echo " install-dev-notebooks - Install dev + notebook dependencies" - @echo " install-pre-commit - Install pre-commit hooks (run once after cloning)" - @echo "" - @echo " format - Format and fix code" - @echo " format-check - Check format and lint (read-only)" - @echo " typecheck - Run blocking type checks" - @echo " copyright - Add missing SPDX headers to source files" - @echo " copyright-check - Check all source files have SPDX headers (read-only)" - @echo " check - Run all read-only checks" - @echo " lock-check - Check uv.lock is up to date" - @echo "" - @echo " test - Run all unit tests" - @echo " test-e2e - Run end-to-end tests" - @echo " coverage - Run tests with coverage report" - @echo "" - @echo " build-wheel - Build wheel (version from git tag)" - @echo " publish-pypi - Publish wheel to PyPI" - @echo "" - @echo " install-dev-docs - Install dev + docs dependencies" - @echo " docs-serve - Start docs dev server (live-reload)" - @echo " docs-build - Build docs site (strict mode)" - @echo " convert-notebooks - Convert .py tutorials to .ipynb with outputs" - @echo "" - @echo " clean - Remove coverage reports and cache files" - @echo " clean-merged-branches - Checkout main, fetch --prune, delete local branches merged into main" - @echo "" - -bootstrap: - @echo "Installing Python dependencies (dev group)..." - uv sync --group dev - -install: - @echo "Installing project dependencies..." - uv sync - @echo "Done!" - -install-dev: - @echo "Installing project with dev dependencies..." - uv sync --group dev - @echo "Done!" - -install-dev-notebooks: - @echo "Installing project with dev + notebook dependencies..." - uv sync --group dev --group notebooks - @echo "Done!" - -install-pre-commit: - @echo "Installing pre-commit hooks..." - uv run pre-commit install - @echo "Done! Hooks will run on git commit." - -format: - @echo "Formatting and fixing code..." - uv run tools/codestyle/format.sh - -format-check: - @echo "Checking format and lint (read-only)..." - uv run tools/codestyle/format.sh --check - uv run tools/codestyle/ruff_check.sh - -typecheck: - @echo "Running type checks..." - uv run --group docs tools/codestyle/typecheck.sh - -copyright: - @echo "Adding missing SPDX headers..." - uv run tools/codestyle/copyright_fixer.py . - -copyright-check: - @echo "Checking SPDX headers (read-only)..." - uv run tools/codestyle/copyright_fixer.py --check . +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 -check: - @echo "Running all read-only checks..." - $(MAKE) format-check typecheck lock-check copyright-check +# Mise discovery and bootstrap entry points. +# Developer commands live in .mise/tasks/ and run via `mise run`. -lock-check: - @echo "Checking uv.lock is up to date..." - uv lock --check +SHELL := /bin/bash +export PATH := $(HOME)/.local/share/mise/shims:$(HOME)/.local/bin:$(PATH) -test: - @echo "Running unit tests..." - uv run --group dev pytest +MISE_GPG_KEY := 24853EC9F655CE80B48E6C3A8B81C9D17413A06D -test-e2e: - @echo "Running end-to-end tests..." - uv run --group dev pytest -m e2e tests_e2e/test_e2e.py - -coverage: - @echo "Running tests with coverage analysis..." - uv run --group dev pytest --cov=anonymizer --cov-report=term-missing --cov-report=html - @echo "Coverage report generated in htmlcov/index.html" - -build-wheel: - @echo "Building wheel (version from git tag via uv-dynamic-versioning)..." - rm -rf dist/ - uv build --wheel - -publish-pypi: build-wheel - @echo "Publishing wheel to PyPI..." - uvx twine upload \ - --username "$$TWINE_USERNAME" \ - --password "$$TWINE_PASSWORD" \ - --non-interactive \ - dist/*.whl - @echo "published: $$(ls dist/*.whl)" - -install-dev-docs: - @echo "Installing dev + docs dependencies..." - uv sync --group dev --group docs - -docs-serve: - @echo "Starting docs dev server (live-reload)..." - uv run --group docs mkdocs serve - -docs-build: - @echo "Building docs site..." - uv run --group docs mkdocs build --strict - -convert-notebooks: - @echo "Converting Python tutorials to notebooks and executing..." - @mkdir -p docs/notebooks - uv run --group notebooks python -m ipykernel install --user --name anonymizer-venv - uv run --group notebooks --group docs jupytext --to ipynb --set-kernel anonymizer-venv --execute docs/notebook_source/*.py - mv docs/notebook_source/*.ipynb docs/notebooks/ - @echo "Notebooks created in docs/notebooks/" - -clean-pycache: - @echo "Cleaning Python cache files..." - find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - find . -type f -name "*.pyc" -delete 2>/dev/null || true - -clean: clean-pycache - @echo "Cleaning coverage reports and test cache..." - rm -rf htmlcov .coverage .coverage.* .pytest_cache +.PHONY: help +help: + @mise tasks -clean-merged-branches: - @echo "Cleaning merged local branches..." - git checkout main && git fetch --prune && git branch --merged | grep -v '^\*\|main' | xargs -n 1 git branch -d || true - @echo "Done!" +.PHONY: install-mise +install-mise: + @MISE_GPG_KEY=$(MISE_GPG_KEY) bash tools/install-mise.sh -.PHONY: help bootstrap install install-dev install-dev-notebooks install-pre-commit format format-check typecheck copyright copyright-check check lock-check test test-e2e coverage build-wheel publish-pypi install-dev-docs docs-serve docs-build clean clean-pycache clean-merged-branches convert-notebooks +.PHONY: setup +setup: install-mise + @MISE_YES=1 mise trust + @MISE_YES=1 mise run setup diff --git a/README.md b/README.md index 28ff3144..961902ea 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Or install from source: ```bash git clone https://github.com/NVIDIA-NeMo/Anonymizer.git cd Anonymizer -make install +make setup ``` ### 2. Set up model providers @@ -146,14 +146,52 @@ After installation, invoke it with `/anonymizer` in an agent that supports slash ## Development ```bash -make install-dev # Install with dev dependencies -make test # Run tests -make coverage # Run with coverage report -make format-check # Lint + format check (read-only) -anonymizer --help # CLI usage -make install-pre-commit # Install pre-commit hooks +make setup # Install pinned tools, dev dependencies, and hooks +mise run setup all # Install every locked dependency group +mise run test # Run tests +mise run test:all # Run unit and end-to-end tests +mise run test:coverage # Run with coverage report +mise run check # Run all read-only static checks +anonymizer --help # CLI usage +mise run hooks:install # Reinstall repository hooks ``` +Run `mise tasks` to list the available developer commands. Tasks live in `.mise/tasks/` and are used by CI. The +Makefile only exposes `help`, `install-mise`, and `setup`; run developer commands with Mise. + +### Local endpoint credentials + +Mise loads `.env` and `.env.local` from the repository root. Keep endpoint credentials in `.env.local`, which is ignored by Git: + +```bash +# .env.local +NVIDIA_API_KEY=your-nvidia-build-key +OPENAI_API_KEY=your-openai-key +OPENROUTER_API_KEY=your-openrouter-key +``` + +Use `.mise.local.toml` for local Mise-specific overrides. It is ignored by Git, so it can hold redacted environment values when that layout is more convenient: + +```toml +# .mise.local.toml +[env] +NVIDIA_API_KEY = { value = "your-nvidia-build-key", redact = true } +``` + +Do not commit credentials. Provider YAML should refer to environment variable names, never raw keys. + +### Benchmark profiles + +The benchmark task loads the same local credentials and accepts runner options after `--`: + +```bash +mise run benchmark smoke +mise run benchmark smoke-traces +mise run benchmark smoke -- --dry-run +``` + +`smoke` runs the repository data smoke suite. `smoke-traces` adds DataDesigner message and scheduler task traces. Set `BENCHMARK_OUTPUT_DIR` in `.env.local` when you need a different output directory. + --- ## Requirements diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index ebfa40cf..fd536718 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -202,7 +202,7 @@ Every Python file must include `from __future__ import annotations` after the li ## License Headers -Every Python and Markdown file requires an SPDX header at the top (enforced by `tools/codestyle/copyright_fixer.py --check`, run via `make copyright-check`). Files listed in `.copyrightignore` are exempt. +Every Python and Markdown file requires an SPDX header at the top (enforced by `tools/codestyle/copyright_fixer.py --check`, run via `mise run check:license:headers`). Files listed in `.copyrightignore` are exempt. --- diff --git a/docs/notebook_source/04_rewriting_biographies.py b/docs/notebook_source/04_rewriting_biographies.py index 2199678d..2fe50135 100644 --- a/docs/notebook_source/04_rewriting_biographies.py +++ b/docs/notebook_source/04_rewriting_biographies.py @@ -163,7 +163,7 @@ # %% df = result.dataframe -flagged = df[df["needs_human_review"] == True] # noqa: E712 +flagged = df[df["needs_human_review"] == True] print(f"{len(flagged)} of {len(df)} records flagged for human review") flagged.head() diff --git a/docs/notebook_source/05_rewriting_legal_documents.py b/docs/notebook_source/05_rewriting_legal_documents.py index a2354af8..3310025a 100644 --- a/docs/notebook_source/05_rewriting_legal_documents.py +++ b/docs/notebook_source/05_rewriting_legal_documents.py @@ -187,7 +187,7 @@ # %% df = result.dataframe -flagged = df[df["needs_human_review"] == True] # noqa: E712 +flagged = df[df["needs_human_review"] == True] print(f"{len(flagged)} of {len(df)} records flagged for human review") flagged.head() diff --git a/docs/notebooks/04_rewriting_biographies.ipynb b/docs/notebooks/04_rewriting_biographies.ipynb index aad8be15..26f7643d 100644 --- a/docs/notebooks/04_rewriting_biographies.ipynb +++ b/docs/notebooks/04_rewriting_biographies.ipynb @@ -876,7 +876,7 @@ ], "source": [ "df = result.dataframe\n", - "flagged = df[df[\"needs_human_review\"] == True] # noqa: E712\n", + "flagged = df[df[\"needs_human_review\"] == True]\n", "print(f\"{len(flagged)} of {len(df)} records flagged for human review\")\n", "flagged.head()" ] diff --git a/docs/notebooks/05_rewriting_legal_documents.ipynb b/docs/notebooks/05_rewriting_legal_documents.ipynb index ad423f63..89eee149 100644 --- a/docs/notebooks/05_rewriting_legal_documents.ipynb +++ b/docs/notebooks/05_rewriting_legal_documents.ipynb @@ -1121,7 +1121,7 @@ ], "source": [ "df = result.dataframe\n", - "flagged = df[df[\"needs_human_review\"] == True] # noqa: E712\n", + "flagged = df[df[\"needs_human_review\"] == True]\n", "print(f\"{len(flagged)} of {len(df)} records flagged for human review\")\n", "flagged.head()" ] diff --git a/mise.lock b/mise.lock new file mode 100644 index 00000000..63d83a9d --- /dev/null +++ b/mise.lock @@ -0,0 +1,67 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html + +[[tools.ruff]] +version = "0.16.2" +backend = "aqua:astral-sh/ruff" + +[tools.ruff."platforms.linux-arm64"] +checksum = "sha256:7cc696f9d89cfeef02167301feedfc3017061fe45df3688b4f3589cde90a1139" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.2/ruff-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/505191631" +provenance = "github-attestations" + +[tools.ruff."platforms.linux-x64"] +checksum = "sha256:df690c6a80a7c41b6f1154dd0b5dfc152a209cafa06ffd3d4ff30f9751f4965c" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.2/ruff-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/505191732" +provenance = "github-attestations" + +[tools.ruff."platforms.macos-arm64"] +checksum = "sha256:fbf6cbc23d254b0bc03a6fb2b1b04efb917fe5ce068d027e735ce7ed65b9bed6" +url = "https://github.com/astral-sh/ruff/releases/download/0.16.2/ruff-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/505191610" +provenance = "github-attestations" + +[[tools.ty]] +version = "0.0.69" +backend = "aqua:astral-sh/ty" + +[tools.ty."platforms.linux-arm64"] +checksum = "sha256:251c12a59b3eac947011d61d5bcf8d46ac8c4bfcd1c51401bd54b38b2c66c48a" +url = "https://github.com/astral-sh/ty/releases/download/0.0.69/ty-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ty/releases/assets/503782841" +provenance = "github-attestations" + +[tools.ty."platforms.linux-x64"] +checksum = "sha256:4f7f5f6aefa310aad42ad2d36f50d18a19b373df5be49cbf3a8240b566bd6399" +url = "https://github.com/astral-sh/ty/releases/download/0.0.69/ty-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ty/releases/assets/503782964" +provenance = "github-attestations" + +[tools.ty."platforms.macos-arm64"] +checksum = "sha256:9cb3ba0e33ad78f0199e83faf73c37d04097f4026ebadc090082a1791dc5efbb" +url = "https://github.com/astral-sh/ty/releases/download/0.0.69/ty-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/ty/releases/assets/503782821" +provenance = "github-attestations" + +[[tools.uv]] +version = "0.12.3" +backend = "aqua:astral-sh/uv" + +[tools.uv."platforms.linux-arm64"] +checksum = "sha256:fa513fca1eb2913334c944fe9adbdd410274a1cbe8dd05d03699a9eb85311d4e" +url = "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/505387718" +provenance = "github-attestations" + +[tools.uv."platforms.linux-x64"] +checksum = "sha256:0643b9fb8c9fb27458e709ce6ff939695013c41975ff7b02d3f3b138d8d4bdb3" +url = "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/505387821" +provenance = "github-attestations" + +[tools.uv."platforms.macos-arm64"] +checksum = "sha256:546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843" +url = "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/505387707" +provenance = "github-attestations" diff --git a/pyproject.toml b/pyproject.toml index 4c5c4b82..0fa16669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dev = [ "pre-commit>=4.0.0,<5", "pytest>=9.0.3,<10", "pytest-cov>=7.0,<8", - "ruff>=0.12.3,<1", + "ruff==0.16.2", "ty==0.0.69", {include-group = "measurement"}, ] @@ -70,7 +70,7 @@ style = "pep440" packages = ["src/anonymizer"] [tool.uv] -required-version = ">=0.5.0" +required-version = ">=0.12.3" package = true constraint-dependencies = [ diff --git a/ruff.toml b/ruff.toml index d7b98193..ea2132cf 100644 --- a/ruff.toml +++ b/ruff.toml @@ -16,17 +16,22 @@ select = [ "I", # isort "ICN", # flake8-import-conventions "PIE", # flake8-pie + "RUF100", # unused noqa directives "TID", # flake8-tidy-imports (bans relative imports) "UP006", # List[A] -> list[A] "UP007", # Union[A, B] -> A | B + "UP015", # redundant open modes + "UP017", # datetime.timezone.utc -> datetime.UTC + "UP035", # deprecated imports + "UP037", # quoted annotations "UP045", # Optional[A] -> A | None + "E9", # pycodestyle file I/O errors # TODO: enable these rules: # "B006", # mutable default argument # "T201", # no print() in non-test code # "D", # pydocstyle -- Google-style docstrings (add convention = "google" under [lint.pydocstyle]) # "E4", # pycodestyle import errors # "E7", # pycodestyle statement errors - # "E9", # pycodestyle runtime errors ] ignore = [] diff --git a/src/anonymizer/config/models.py b/src/anonymizer/config/models.py index 041bb69d..ffe43739 100644 --- a/src/anonymizer/config/models.py +++ b/src/anonymizer/config/models.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping from typing import Any from pydantic import BaseModel, field_validator @@ -116,3 +117,32 @@ class ModelSelection(BaseModel): replace: ReplaceModelSelection rewrite: RewriteModelSelection evaluate: EvaluateModelSelection + + def with_overrides(self, overrides: Mapping[str, Mapping[str, Any]]) -> ModelSelection: + """Return a fully validated selection with per-workflow overrides applied.""" + detection_overrides = overrides.get("detection", {}) + replace_overrides = overrides.get("replace", {}) + rewrite_overrides = overrides.get("rewrite", {}) + evaluate_overrides = overrides.get("evaluate", {}) + return type(self)( + detection=( + type(self.detection).model_validate({**self.detection.model_dump(), **detection_overrides}) + if detection_overrides + else self.detection + ), + replace=( + type(self.replace).model_validate({**self.replace.model_dump(), **replace_overrides}) + if replace_overrides + else self.replace + ), + rewrite=( + type(self.rewrite).model_validate({**self.rewrite.model_dump(), **rewrite_overrides}) + if rewrite_overrides + else self.rewrite + ), + evaluate=( + type(self.evaluate).model_validate({**self.evaluate.model_dump(), **evaluate_overrides}) + if evaluate_overrides + else self.evaluate + ), + ) diff --git a/src/anonymizer/engine/detection/chunked_validation.py b/src/anonymizer/engine/detection/chunked_validation.py index 711c26ae..21c31205 100644 --- a/src/anonymizer/engine/detection/chunked_validation.py +++ b/src/anonymizer/engine/detection/chunked_validation.py @@ -350,7 +350,8 @@ def _dispatch_chunk( len(facades), ) return output - except Exception as exc: # noqa: BLE001 — we classify by failover position, not type + # Failover position, rather than exception type, determines whether to retry. + except Exception as exc: last_exc = exc remaining = len(facades) - attempt_index - 1 if remaining > 0: @@ -417,7 +418,8 @@ async def _dispatch_chunk_async( len(facades), ) return output - except Exception as exc: # noqa: BLE001 — we classify by failover position, not type + # Failover position, rather than exception type, determines whether to retry. + except Exception as exc: last_exc = exc remaining = len(facades) - attempt_index - 1 if remaining > 0: diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index a260a122..b5f24599 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -5,7 +5,8 @@ import logging import re -from typing import ClassVar, Mapping, TypeVar, cast +from collections.abc import Mapping +from typing import ClassVar, TypeVar, cast import pandas as pd from data_designer.config.column_configs import LLMStructuredColumnConfig diff --git a/src/anonymizer/engine/ndd/adapter.py b/src/anonymizer/engine/ndd/adapter.py index dd6c8001..b4f86b5a 100644 --- a/src/anonymizer/engine/ndd/adapter.py +++ b/src/anonymizer/engine/ndd/adapter.py @@ -521,7 +521,7 @@ def build_config_for_seed( existing shared seed. ``num_jobs > 1`` selects one worker's ordered partition (``job_index`` of ``num_jobs``), matching how the orchestrator shards the seed. """ - from data_designer.config.seed import PartitionBlock # noqa: PLC0415 + from data_designer.config.seed import PartitionBlock if num_jobs < 1: raise ValueError(f"num_jobs must be >= 1, got {num_jobs}") diff --git a/src/anonymizer/engine/ndd/model_loader.py b/src/anonymizer/engine/ndd/model_loader.py index 6f1c4ff6..cd672f5a 100644 --- a/src/anonymizer/engine/ndd/model_loader.py +++ b/src/anonymizer/engine/ndd/model_loader.py @@ -6,11 +6,10 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Any, TypeVar, cast +from typing import Any, cast from data_designer.config.models import ModelConfig, ModelProvider, load_model_configs from data_designer.config.utils.io_helpers import load_config_file -from pydantic import BaseModel from anonymizer.config.models import ( DetectionModelSelection, @@ -22,8 +21,6 @@ DEFAULT_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config" / "default_model_configs" -_ModelSelectionT = TypeVar("_ModelSelectionT", bound=BaseModel) - class WorkflowName(str, Enum): detection = "detection" @@ -209,14 +206,6 @@ def resolve_model_aliases( raise TypeError(f"Role {role!r} does not contain a model alias.") -def _merge_selection(section: _ModelSelectionT, overrides: dict[str, Any]) -> _ModelSelectionT: - """Merge overrides into a typed model-selection section and revalidate it.""" - if not overrides: - return section - merged = {**section.model_dump(), **overrides} - return type(section).model_validate(merged) - - def _merge_selections(user_selections: dict[str, dict[str, str]] | None) -> ModelSelection: """Merge user-provided role selections onto YAML defaults. @@ -231,17 +220,7 @@ def _merge_selections(user_selections: dict[str, dict[str, str]] | None) -> Mode if not user_selections or not isinstance(user_selections, dict): return defaults - detection_overrides = user_selections.get(WorkflowName.detection.value, {}) - replace_overrides = user_selections.get(WorkflowName.replace.value, {}) - rewrite_overrides = user_selections.get(WorkflowName.rewrite.value, {}) - evaluate_overrides = user_selections.get(WorkflowName.evaluate.value, {}) - - return ModelSelection( - detection=_merge_selection(defaults.detection, detection_overrides), - replace=_merge_selection(defaults.replace, replace_overrides), - rewrite=_merge_selection(defaults.rewrite, rewrite_overrides), - evaluate=_merge_selection(defaults.evaluate, evaluate_overrides), - ) + return defaults.with_overrides(user_selections) def validate_model_configs_reference_providers( diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index d7c26eba..240a6bf2 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -862,7 +862,8 @@ def _maybe_emit_telemetry( session_id=uuid.uuid4().hex, ) as handler: handler.enqueue(event) - except Exception: # noqa: BLE001 - best-effort + # Best-effort telemetry must not affect anonymization. + except Exception: logger.debug("Failed to emit telemetry event", exc_info=True) def _build_telemetry_event( diff --git a/src/anonymizer/measurement/records/row.py b/src/anonymizer/measurement/records/row.py index 15f43d89..42db3322 100644 --- a/src/anonymizer/measurement/records/row.py +++ b/src/anonymizer/measurement/records/row.py @@ -285,8 +285,11 @@ def _llm_record_fields( repair_iterations=repair_iterations, replace_map_generation_uses_llm=replace_map_generation_uses_llm, ) - known_call_counts = [value for value in calls_by_stage.values() if value is not None] - total_estimated = sum(known_call_counts) if len(known_call_counts) == len(calls_by_stage) else None + total_estimated = ( + sum(value for value in calls_by_stage.values() if value is not None) + if all(value is not None for value in calls_by_stage.values()) + else None + ) return { "detected_candidate_count": detected_candidate_count, "validation_chunk_count": validation_chunk_count, diff --git a/src/anonymizer/telemetry.py b/src/anonymizer/telemetry.py index dce052f4..cc8bf413 100644 --- a/src/anonymizer/telemetry.py +++ b/src/anonymizer/telemetry.py @@ -26,7 +26,7 @@ import os import platform from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar @@ -249,7 +249,7 @@ class QueuedEvent: def _get_iso_timestamp(dt: datetime | None = None) -> str: if dt is None: - dt = datetime.now(timezone.utc) + dt = datetime.now(UTC) return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z" @@ -333,7 +333,7 @@ def enqueue(self, event: AnonymizerEvent) -> None: return if not isinstance(event, AnonymizerEvent): return - self._events.append(QueuedEvent(event=event, timestamp=datetime.now(timezone.utc))) + self._events.append(QueuedEvent(event=event, timestamp=datetime.now(UTC))) def flush(self) -> None: if not (self._events or self._dlq): diff --git a/tests/engine/test_chunked_validation.py b/tests/engine/test_chunked_validation.py index 31b74e03..152fd629 100644 --- a/tests/engine/test_chunked_validation.py +++ b/tests/engine/test_chunked_validation.py @@ -13,8 +13,9 @@ import asyncio import json import re +from collections.abc import Callable from types import SimpleNamespace -from typing import Any, Callable +from typing import Any import pytest from data_designer.engine.models.clients.errors import SyncClientUnavailableError diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index ac0666a8..078c174e 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -5,7 +5,7 @@ import asyncio import json -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -278,7 +278,7 @@ def test_all_task_statuses(self) -> None: class TestBuildPayload: def _make_queued(self, *, task: TaskEnum = TaskEnum.BATCH) -> QueuedEvent: event = _minimal_event(task=task) - return QueuedEvent(event=event, timestamp=datetime(2026, 5, 11, 12, 0, 0, tzinfo=timezone.utc)) + return QueuedEvent(event=event, timestamp=datetime(2026, 5, 11, 12, 0, 0, tzinfo=UTC)) def test_envelope_shape(self) -> None: payload = build_payload([self._make_queued()], source_client_version="1.2.3", session_id="anonymizer-abc") @@ -356,7 +356,7 @@ class TestSendSemantics: def _make(self) -> tuple[TelemetryHandler, QueuedEvent]: handler = TelemetryHandler(source_client_version="1.0", session_id="s1") event = _minimal_event() - return handler, QueuedEvent(event=event, timestamp=datetime.now(timezone.utc)) + return handler, QueuedEvent(event=event, timestamp=datetime.now(UTC)) def test_successful_send_does_not_dlq(self) -> None: handler, q = self._make() @@ -390,7 +390,7 @@ def test_400_does_not_dlq(self) -> None: def test_413_splits_and_retries(self) -> None: handler = TelemetryHandler(source_client_version="1.0", session_id="s1") - events = [QueuedEvent(event=_minimal_event(), timestamp=datetime.now(timezone.utc)) for _ in range(2)] + events = [QueuedEvent(event=_minimal_event(), timestamp=datetime.now(UTC)) for _ in range(2)] too_large = MagicMock(status_code=413, is_success=False) success = MagicMock(status_code=200, is_success=True) mock_client = AsyncMock() @@ -460,7 +460,7 @@ class TestFlushFromRunningLoop: def test_run_sync_uses_thread_when_loop_is_running(self) -> None: sent: list[int] = [] - async def fake_flush(self) -> None: # noqa: ARG001 - bound-method signature + async def fake_flush(self) -> None: sent.append(1) async def driver() -> None: @@ -469,7 +469,7 @@ async def driver() -> None: # want to prove _run_sync drove the coroutine to completion from # inside a running loop. with patch.object(TelemetryHandler, "_flush_events", new=fake_flush): - handler._events.append(QueuedEvent(event=_minimal_event(), timestamp=datetime.now(timezone.utc))) + handler._events.append(QueuedEvent(event=_minimal_event(), timestamp=datetime.now(UTC))) handler.flush() asyncio.run(driver()) @@ -483,7 +483,7 @@ def test_context_manager_flushes_from_running_loop(self, monkeypatch: pytest.Mon monkeypatch.setenv("NEMO_TELEMETRY_ENABLED", "true") sent: list[int] = [] - async def fake_send(self, client, events): # noqa: ARG001 - mirror real signature + async def fake_send(self, client, events): sent.append(len(events)) async def driver() -> None: diff --git a/tests/tools/test_benchmark_task.sh b/tests/tools/test_benchmark_task.sh new file mode 100755 index 00000000..6e9b6f76 --- /dev/null +++ b/tests/tools/test_benchmark_task.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +task="$repo_root/.mise/tasks/benchmark" +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/anonymizer-benchmark-task.XXXXXXXX")" +trap 'rm -rf "$tmpdir"' EXIT + +cat >"$tmpdir/uv" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" >"${CAPTURE_FILE:?}" +EOF +chmod +x "$tmpdir/uv" + +run_task() { + local profile="$1" + local runner_args="${2:-}" + local capture_file="$tmpdir/${profile}.actual" + + PATH="$tmpdir:$PATH" \ + CAPTURE_FILE="$capture_file" \ + usage_profile="$profile" \ + usage_runner_args="$runner_args" \ + bash "$task" +} + +run_task smoke +cat >"$tmpdir/smoke.expected" <<'EOF' +run +--locked +--group +dev +python +tools/measurement/run_benchmarks.py +tools/measurement/examples/repo-data-smoke.yaml +--output +benchmark-runs/smoke +EOF +diff -u "$tmpdir/smoke.expected" "$tmpdir/smoke.actual" + +run_task smoke-traces +cat >"$tmpdir/smoke-traces.expected" <<'EOF' +run +--locked +--group +dev +python +tools/measurement/run_benchmarks.py +tools/measurement/examples/repo-data-smoke.yaml +--output +benchmark-runs/smoke-traces +--dd-trace +last_message +--dd-task-trace +EOF +diff -u "$tmpdir/smoke-traces.expected" "$tmpdir/smoke-traces.actual" + +run_task smoke "--wandb-run-name 'test run'" +cat >>"$tmpdir/smoke.expected" <<'EOF' +--wandb-run-name +test run +EOF +diff -u "$tmpdir/smoke.expected" "$tmpdir/smoke.actual" diff --git a/tests/tools/test_mise_configuration.py b/tests/tools/test_mise_configuration.py new file mode 100644 index 00000000..1b899322 --- /dev/null +++ b/tests/tools/test_mise_configuration.py @@ -0,0 +1,233 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import re +import subprocess +import tomllib +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _read_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as stream: + return tomllib.load(stream) + + +def test_github_setup_requires_signed_mise_installer() -> None: + action = yaml.safe_load((REPO_ROOT / ".github/actions/setup-python-env/action.yml").read_text(encoding="utf-8")) + install_step = next(step for step in action["runs"]["steps"] if "tools/install-mise.sh" in step.get("run", "")) + + assert install_step.get("env", {}).get("MISE_REQUIRE_SIGNED_INSTALL") == "1" + + +def test_benchmark_workflow_keeps_setup_on_workflow_revision() -> None: + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/benchmark-ci.yml").read_text(encoding="utf-8")) + steps = workflow["jobs"]["benchmark"]["steps"] + + assert steps[0]["uses"] == "actions/checkout@v6" + assert steps[1]["uses"] == "./.github/actions/setup-python-env" + assert steps[1]["with"]["checkout"] == "false" + assert steps[2]["uses"] == "actions/checkout@v6" + assert steps[2]["with"] == { + "ref": "${{ env.BENCHMARK_REF }}", + "fetch-depth": "0", + "path": "benchmark-target", + } + + target_steps = [ + step + for step in steps + if step.get("id") == "target" or step.get("name") in {"Run benchmark suite", "Add benchmark summary"} + ] + assert target_steps + assert all(step["working-directory"] == "benchmark-target" for step in target_steps) + + upload_step = next(step for step in steps if step.get("uses") == "actions/upload-artifact@v4") + assert upload_step["with"]["path"] == "benchmark-target/${{ env.BENCHMARK_OUTPUT_DIR }}/" + + +def test_local_mise_installer_keeps_unsigned_fallback_opt_in() -> None: + installer = (REPO_ROOT / "tools/install-mise.sh").read_text(encoding="utf-8") + + assert 'REQUIRE_SIGNED_INSTALL="${MISE_REQUIRE_SIGNED_INSTALL:-0}"' in installer + + +def test_local_mise_installer_fetches_and_pins_release_key_over_https() -> None: + installer = (REPO_ROOT / "tools/install-mise.sh").read_text(encoding="utf-8") + + assert 'MISE_GPG_KEY_URL="https://keys.openpgp.org/vks/v1/by-fingerprint"' in installer + assert '"${MISE_GPG_KEY_URL}/${MISE_GPG_KEY}"' in installer + assert 'grep -q "^fpr:::::::::${MISE_GPG_KEY}:"' in installer + assert '--recv-keys "$MISE_GPG_KEY"' not in installer + + +def test_local_mise_installer_fetches_signature_for_pinned_version() -> None: + installer = (REPO_ROOT / "tools/install-mise.sh").read_text(encoding="utf-8") + + assert 'MISE_SIG_URL="https://github.com/jdx/mise/releases/download/${MISE_VERSION}/install.sh.sig"' in installer + assert "https://mise.jdx.dev/install.sh.sig" not in installer + + +def test_local_mise_installer_downloads_unsigned_fallback_before_execution() -> None: + installer = (REPO_ROOT / "tools/install-mise.sh").read_text(encoding="utf-8") + + assert 'curl_fetch -o "$unsigned_script" "$MISE_RUN_URL"' in installer + assert 'MISE_VERSION="$MISE_VERSION" sh "$unsigned_script"' in installer + assert 'curl_fetch "$MISE_RUN_URL" |' not in installer + + +def test_makefile_exposes_bootstrap_without_deprecated_task_aliases() -> None: + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + phony_targets = [line.removeprefix(".PHONY: ") for line in makefile.splitlines() if line.startswith(".PHONY: ")] + + assert phony_targets == ["help", "install-mise", "setup"] + assert "deprecated_target" not in makefile + + +def test_mise_typecheck_preserves_blocking_repository_contract() -> None: + quality_tasks = _read_toml(REPO_ROOT / ".mise/tasks/quality.toml") + typecheck = quality_tasks["check:type"] + + assert typecheck["description"].startswith("Run blocking ty checks") + assert typecheck["run"] == "uv run --locked --group docs tools/codestyle/typecheck.sh" + + +def test_mise_sources_the_uv_managed_environment() -> None: + mise = _read_toml(REPO_ROOT / ".mise.toml") + + assert "VIRTUAL_ENV" not in mise["env"] + assert mise["settings"]["python"]["uv_venv_auto"] == "source" + + +def test_mise_uses_native_task_composition() -> None: + quality_tasks = _read_toml(REPO_ROOT / ".mise/tasks/quality.toml") + publish_tasks = _read_toml(REPO_ROOT / ".mise/tasks/publish.toml") + setup_tasks = _read_toml(REPO_ROOT / ".mise/tasks/setup.toml") + clean_task = (REPO_ROOT / ".mise/tasks/clean/_default").read_text(encoding="utf-8") + notebook_task = (REPO_ROOT / ".mise/tasks/notebooks/execute").read_text(encoding="utf-8") + + assert set(quality_tasks["check"]["depends"]) == { + "check:format", + "check:license:headers", + "check:lint", + "check:lock", + "check:type", + } + assert {"task": "build:wheel"} in publish_tasks["publish:pypi"]["run"] + assert setup_tasks["setup"]["run"][2] == {"task": "deps:sync", "args": ["{{usage.profile}}"]} + assert '#MISE depends=["clean:pycache"]' in clean_task + assert "mise run clean:pycache" not in clean_task + assert "mise run deps:sync notebooks" not in notebook_task + + +def test_mise_dependency_profiles_require_current_lockfile() -> None: + setup_tasks = _read_toml(REPO_ROOT / ".mise/tasks/setup.toml") + sync_task = setup_tasks["deps:sync"] + + assert sync_task["run"].count("uv sync --locked") == 5 + assert 'choices "runtime" "dev" "docs" "notebooks" "all"' in sync_task["usage"] + assert "runtime) uv sync --locked --no-default-groups" in sync_task["run"] + for profile in ("runtime", "dev", "docs", "notebooks", "all"): + assert f"{profile}) uv sync --locked" in sync_task["run"] + assert "all) uv sync --locked --all-groups" in sync_task["run"] + + +def test_mise_test_all_composes_unit_and_end_to_end_suites() -> None: + test_tasks = _read_toml(REPO_ROOT / ".mise/tasks/tests.toml") + + assert test_tasks["test:all"]["run"] == [{"task": "test"}, {"task": "test:e2e"}] + + +def test_mise_task_tree_uses_colon_delimited_vocabulary() -> None: + completed = subprocess.run( + ["mise", "tasks", "--json"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + tasks = json.loads(completed.stdout) + task_names = {task["name"] for task in tasks} + + assert all(re.fullmatch(r"[a-z0-9]+(?::[a-z0-9]+)*", name) for name in task_names) + assert all(not task["aliases"] for task in tasks) + assert { + "build:wheel", + "check:format", + "check:lint", + "check:lock", + "check:type", + "deps:sync", + "hooks:install", + "lock:update", + "notebooks:execute", + "test:all", + "test:coverage", + } <= task_names + assert "validate" not in task_names + + +def test_github_setup_preserves_requested_python_and_dependency_profile() -> None: + action = (REPO_ROOT / ".github/actions/setup-python-env/action.yml").read_text(encoding="utf-8") + + assert 'export UV_PYTHON="${{ inputs.python-version }}"' in action + assert 'mise run setup "${{ inputs.dependency-profile }}" --no-hooks' in action + + +def test_precommit_lock_check_is_read_only() -> None: + config = yaml.safe_load((REPO_ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")) + local_repo = next(repo for repo in config["repos"] if repo["repo"] == "local") + check_hook = next(hook for hook in local_repo["hooks"] if hook["id"] == "check") + + assert check_hook["entry"] == "mise run check" + assert all(hook["id"] != "uv-lock" for hook in local_repo["hooks"]) + + +def test_toml_tasks_use_portable_shell_conditionals() -> None: + task_text = "\n".join(path.read_text(encoding="utf-8") for path in (REPO_ROOT / ".mise/tasks").glob("*.toml")) + + assert "[[" not in task_text + + +def test_clean_branches_does_not_switch_the_callers_worktree() -> None: + task = (REPO_ROOT / ".mise/tasks/clean/branches").read_text(encoding="utf-8") + + assert "git checkout" not in task + assert "--merged origin/main" in task + assert "git branch -d --" in task + + +def test_ruff_enables_audited_error_and_safe_fix_rules() -> None: + ruff = _read_toml(REPO_ROOT / "ruff.toml") + + assert {"E9", "RUF100", "UP015", "UP017", "UP035", "UP037"} <= set(ruff["lint"]["select"]) + + +def test_ruff_and_ty_check_rendered_notebooks() -> None: + ruff = _read_toml(REPO_ROOT / "ruff.toml") + project = _read_toml(REPO_ROOT / "pyproject.toml") + file_collector = (REPO_ROOT / "tools/codestyle/_lib.sh").read_text(encoding="utf-8") + + assert "docs/notebooks/*.ipynb" not in ruff["exclude"] + assert "git ls-files '*.py' '*.ipynb'" in file_collector + assert "docs" in project["tool"]["ty"]["src"]["include"] + + +def test_mise_python_tool_versions_match_project_versions() -> None: + mise = _read_toml(REPO_ROOT / ".mise.toml") + project = _read_toml(REPO_ROOT / "pyproject.toml") + + for tool in ("ruff", "ty"): + requirement = next( + requirement for requirement in project["dependency-groups"]["dev"] if requirement.startswith(f"{tool}==") + ) + assert requirement == f"{tool}=={mise['tools'][tool]}" + + assert project["tool"]["uv"]["required-version"] == f">={mise['tools']['uv']}" diff --git a/tools/codestyle/_lib.sh b/tools/codestyle/_lib.sh index 18ac412b..3cdb9bb1 100755 --- a/tools/codestyle/_lib.sh +++ b/tools/codestyle/_lib.sh @@ -39,8 +39,8 @@ require_tool() { # collect_py_files -- parse args and populate PY_FILES array # # Modes: -# (no args) → all tracked .py files -# file1.py file2.py ... → those exact files +# (no args) → all tracked .py and .ipynb files +# file1.py notebook.ipynb ... → those exact files # # Also strips --check from the arg list and exports CHECK_MODE. # @@ -67,12 +67,12 @@ collect_py_files() { else local IFS=$'\n' # shellcheck disable=SC2207 - PY_FILES=($(git ls-files '*.py')) + PY_FILES=($(git ls-files '*.py' '*.ipynb')) unset IFS fi if [[ ${#PY_FILES[@]} -eq 0 ]]; then - echo "No Python files to check" >&2 + echo "No Python files or notebooks to check" >&2 return 0 fi } diff --git a/tools/codestyle/copyright_fixer.py b/tools/codestyle/copyright_fixer.py index 76072fb6..91e5a123 100755 --- a/tools/codestyle/copyright_fixer.py +++ b/tools/codestyle/copyright_fixer.py @@ -85,7 +85,7 @@ def _load_ruff_excludes(repo_root: str | None) -> list[str]: excludes = ruff_section.get("exclude", []) if excludes: return excludes - except Exception: # noqa: BLE001 + except Exception: continue return [] @@ -149,7 +149,7 @@ def _has_header(head: str) -> bool: def _read_head(path: str, nbytes: int = 512) -> str: """Read the first *nbytes* of a file (fast, no full-file read).""" try: - with open(path, "r", encoding="utf-8", errors="replace") as f: + with open(path, encoding="utf-8", errors="replace") as f: return f.read(nbytes) except OSError: return "" @@ -197,7 +197,7 @@ def _get_header_for_ext(ext: str) -> str: def _add_header(filepath: str) -> bool: """Add the copyright header to *filepath*. Returns True if modified.""" try: - content = open(filepath, "r", encoding="utf-8").read() # noqa: SIM115 + content = open(filepath, encoding="utf-8").read() except (OSError, UnicodeDecodeError): return False diff --git a/tools/codestyle/format.sh b/tools/codestyle/format.sh index b0ec6d80..40cf7e0e 100755 --- a/tools/codestyle/format.sh +++ b/tools/codestyle/format.sh @@ -3,13 +3,13 @@ # SPDX-License-Identifier: Apache-2.0 # -# format.sh -- format (or check formatting of) Python files with ruff +# format.sh -- format (or check formatting of) Python files and notebooks with ruff # # Usage: -# ./format.sh # fix mode: ruff format + ruff check --fix -# ./format.sh --check # check mode: ruff format --check (exit 1 if unformatted) -# ./format.sh src/foo.py bar.py # fix specific files -# ./format.sh --check src/foo.py # check mode on specific files +# ./format.sh # fix all tracked Python files and notebooks +# ./format.sh --check # check all tracked Python files and notebooks +# ./format.sh src/foo.py demo.ipynb # fix specific files +# ./format.sh --check src/foo.py # check specific files # # Lint-rule violations (ruff check without --fix) are handled by ruff_check.sh. # Copyright headers are handled separately by copyright_fixer.py. diff --git a/tools/codestyle/ruff_check.sh b/tools/codestyle/ruff_check.sh index 976a079c..34b8204a 100755 --- a/tools/codestyle/ruff_check.sh +++ b/tools/codestyle/ruff_check.sh @@ -3,16 +3,16 @@ # SPDX-License-Identifier: Apache-2.0 # -# ruff_check.sh -- check Python files for lint-rule violations (read-only) +# ruff_check.sh -- check Python files and notebooks for lint violations (read-only) # # This is the read-only counterpart to `ruff check --fix` in format.sh. # ruff exposes format and check as separate commands; this script wraps -# the read-only check so CI and `make format-check` can verify lint rules +# the read-only check so CI and `mise run check:lint` can verify lint rules # without modifying files. # # Usage: -# ./ruff_check.sh # all tracked .py files -# ./ruff_check.sh src/foo.py bar.py # specific files +# ./ruff_check.sh # all tracked .py and .ipynb files +# ./ruff_check.sh src/foo.py demo.ipynb # specific files # set -euo pipefail diff --git a/tools/codestyle/typecheck.sh b/tools/codestyle/typecheck.sh index 34673c33..4af08051 100755 --- a/tools/codestyle/typecheck.sh +++ b/tools/codestyle/typecheck.sh @@ -3,11 +3,11 @@ # SPDX-License-Identifier: Apache-2.0 # -# typecheck.sh -- run ty type checks on Python files +# typecheck.sh -- run ty type checks on Python files and notebooks # # Usage: # ./typecheck.sh # configured repository paths -# ./typecheck.sh src/foo.py bar.py # specific files +# ./typecheck.sh src/foo.py demo.ipynb # specific files # set -euo pipefail diff --git a/tools/install-mise.sh b/tools/install-mise.sh new file mode 100755 index 00000000..1778b5dd --- /dev/null +++ b/tools/install-mise.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# +# install-mise.sh -- install the pinned mise version, preferring the +# GPG-verified path when gpg is available. +# +# Version source: +# `.mise.toml` `min_version` is the single source of truth. Set MISE_VERSION +# only to override for testing. +# +# Inputs: +# MISE_GPG_KEY fingerprint of the mise release signing key (required) +# +# Optional env: +# MISE_VERSION override pinned version (default: read from .mise.toml) +# MISE_REQUIRE_SIGNED_INSTALL=1 fail instead of falling back to the +# unsigned mise.run installer when the +# signed path can't be completed (missing +# gpg or CDN/network flake). Recommended +# for CI/release pipelines. +# +# Behaviour: +# - If mise is already on PATH at the pinned version, exit early. +# - If `mise --version` returns nothing, abort with a pointer to +# MISE_VERBOSE=1 and the binary path (broken/partial install). +# - If mise is already on PATH at a different version, abort with an +# actionable message (we don't silently clobber the user's install). +# - If gpg is available, fetch the release signing key over HTTPS +# (keys.openpgp.org VKS, not gpg --recv-keys / dirmngr -- dirmngr's +# bundled DNS resolver hangs indefinitely on some corporate networks +# and GnuPG 2.x ignores legacy keyserver timeout options), assert the +# imported key fingerprint matches MISE_GPG_KEY, fetch install.sh.sig, +# verify its GPG signature against a temporary GNUPGHOME (so we don't +# mutate the user's keyring), and run the embedded install script. All +# HTTP fetches are bounded by timeouts and retried a few times on failure. +# - If any of the above fails and MISE_REQUIRE_SIGNED_INSTALL != 1, fall +# back to https://mise.run (no signature verification; warn loudly). +# - In either install path, pass the pinned version through to the installer +# via the documented MISE_VERSION env var so the installed binary +# matches `.mise.toml` `min_version`. +# - Assert the installed binary reports the pinned version before exit. +# +# See: https://mise.jdx.dev/installing-mise.html +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +readonly MISE_CONFIG="${SCRIPT_DIR}/../.mise.toml" + +read_pinned_mise_version() { + local raw + raw="$(grep -E '^min_version[[:space:]]*=' "$MISE_CONFIG" | head -1 \ + | sed -E "s/^min_version[[:space:]]*=[[:space:]]*['\"]([^'\"]+)['\"].*/\1/")" + if [[ -z "$raw" ]]; then + echo "ERROR: min_version not found in ${MISE_CONFIG}" >&2 + exit 1 + fi + if [[ "$raw" == v* ]]; then + printf '%s\n' "$raw" + else + printf 'v%s\n' "$raw" + fi +} + +# ${VAR:?msg} aborts the script with the given message when VAR is unset or +# empty. Paired with `:` (the no-op builtin) it becomes an assert-and-die +# guard that surfaces a clearer error than `set -u`'s "unbound variable". +MISE_VERSION="${MISE_VERSION:-$(read_pinned_mise_version)}" +: "${MISE_GPG_KEY:?MISE_GPG_KEY is required (pass from bootstrap caller)}" + +readonly MISE_SIG_URL="https://github.com/jdx/mise/releases/download/${MISE_VERSION}/install.sh.sig" +readonly MISE_RUN_URL="https://mise.run" +readonly MISE_GPG_KEY_URL="https://keys.openpgp.org/vks/v1/by-fingerprint" + +# Network knobs, applied to every HTTP call so a flaky CDN doesn't wedge +# `make setup` indefinitely in CI/container contexts. +readonly CURL_CONNECT_TIMEOUT=10 +readonly CURL_MAX_TIME=60 +readonly CURL_RETRIES=3 +readonly CURL_RETRY_DELAY=2 +# Set MISE_REQUIRE_SIGNED_INSTALL=1 to fail hard when the signed path can't +# be completed (missing gpg or network failure fetching the key / installer) +# instead of falling back to the unsigned mise.run installer. Recommended +# for CI/release pipelines; default is off so local dev on slim images still +# succeeds with a loud warning. The fallback is downloaded completely before +# execution so curl retries cannot concatenate partial responses into a pipe. +REQUIRE_SIGNED_INSTALL="${MISE_REQUIRE_SIGNED_INSTALL:-0}" + +curl_fetch() { + # Fetch to a file (via -o) rather than a pipe when the consumer is gpg: + # curl --retry can emit partial bytes before retrying, which would leave + # gpg with a truncated then re-sent stream. --retry-all-errors covers + # transient HTTP 5xx as well as connection failures. + curl -fsSL \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$CURL_MAX_TIME" \ + --retry "$CURL_RETRIES" \ + --retry-delay "$CURL_RETRY_DELAY" \ + --retry-all-errors \ + "$@" +} + +# Retries live only in curl_fetch -- do not wrap this in another retry loop +# (curl's --max-time resets per attempt, so nested retries can stretch for +# many minutes before the unsigned fallback). +gpg_import_release_key() { + local key_file="${GNUPGHOME}/mise-release-key.asc" + curl_fetch -H 'Accept: application/pgp-keys' \ + -o "$key_file" \ + "${MISE_GPG_KEY_URL}/${MISE_GPG_KEY}" + gpg --batch --no-tty --import "$key_file" + rm -f "$key_file" + # The URL is not a guarantee: a TLS-intercepting proxy could serve a + # substitute key. Verify the imported fingerprint matches the pin before + # trusting any signature it makes. + gpg --batch --no-tty --with-colons --list-keys "0x${MISE_GPG_KEY}" \ + | grep -q "^fpr:::::::::${MISE_GPG_KEY}:" +} + +unsigned_install_or_fail() { + local reason="$1" + if [[ "$REQUIRE_SIGNED_INSTALL" == "1" ]]; then + echo "ERROR: ${reason}; MISE_REQUIRE_SIGNED_INSTALL=1 forbids unsigned fallback" >&2 + exit 1 + fi + echo "WARNING: ${reason} -- installing mise ${MISE_VERSION} via ${MISE_RUN_URL} without signature verification" >&2 + ( + local unsigned_script + unsigned_script="$(mktemp "${TMPDIR:-/tmp}/mise-install.unsigned.XXXXXXXX")" + trap 'rm -f "$unsigned_script"' EXIT + curl_fetch -o "$unsigned_script" "$MISE_RUN_URL" + MISE_VERSION="$MISE_VERSION" sh "$unsigned_script" + ) +} + +expected="${MISE_VERSION#v}" + +# version_ge A B -> true when A >= B for dotted numeric versions (e.g. +# 2026.5.15). Hand-rolled rather than `sort -V`: version sort is GNU +# coreutils only, and the BSD `sort` shipped with macOS rejects -V, which +# would make every comparison fail and reject an otherwise-valid mise. +# Missing components are treated as 0 (1.2 == 1.2.0); 10# forces base-10 so +# zero-padded fields like "05" aren't read as octal. +version_ge() { + local IFS=. + local -a a=($1) b=($2) + local i n=${#a[@]} + ((${#b[@]} > n)) && n=${#b[@]} + for ((i = 0; i < n; i++)); do + local x="${a[i]:-0}" y="${b[i]:-0}" + ((10#$x > 10#$y)) && return 0 + ((10#$x < 10#$y)) && return 1 + done + return 0 +} + +# mise --version prints " ()" to stdout plus "available +# update" nags to stderr, so take only the first field on stdout. +# +# Under `set -euo pipefail`, a non-zero `mise --version` (corrupted binary, +# missing shared lib, etc.) would abort the script before the empty-string +# diagnostic below can run. Swallow the exit status so callers always see +# either a version string or "", and route the real error through the +# explicit "produced no output" branch. +current_mise_version() { + local out + out="$(mise --version 2>/dev/null || true)" + printf '%s\n' "$out" | awk '{print $1; exit}' +} + +if command -v mise >/dev/null 2>&1; then + installed="$(current_mise_version)" + if [[ -z "$installed" ]]; then + mise_path="$(command -v mise)" + echo "ERROR: \`mise --version\` (at ${mise_path}) produced no output" >&2 + echo " the install looks broken/partial; rerun with MISE_VERBOSE=1 for details" >&2 + echo " or remove ${mise_path} and rerun 'make setup'" >&2 + exit 1 + fi + # `.mise.toml` pins min_version, so any version >= the pinned one is + # acceptable; only abort when the installed binary is older. + if version_ge "$installed" "$expected"; then + echo "mise ${installed} already installed (satisfies min ${expected})" + exit 0 + fi + echo "ERROR: found mise ${installed} on PATH but this repo requires >= ${MISE_VERSION}" >&2 + echo " run 'mise self-update' or uninstall the current mise and rerun 'make setup'" >&2 + exit 1 +fi + +echo "mise not found -- installing ${MISE_VERSION}..." + +if command -v gpg >/dev/null 2>&1; then + echo "Verifying installer signature..." + + # Isolate verification in an ephemeral GNUPGHOME so we don't mutate the + # user's long-lived keyring (importing the mise release key, trust db + # updates, spawning a persistent gpg-agent, etc.). The trap cleans up + # the tmp script and the keyring directory (including the agent socket) + # on any exit path. + # `mktemp` isn't in POSIX and the no-arg form is a GNU/modern-BSD + # extension. Passing an explicit template as a positional argument is the + # form every implementation agrees on, and the named prefix keeps any + # leaked temp paths self-identifying. + tmp_prefix="${TMPDIR:-/tmp}/mise-install" + gnupg_home="$(mktemp -d "${tmp_prefix}.gnupg.XXXXXXXX")" + tmpscript="$(mktemp "${tmp_prefix}.sh.XXXXXXXX")" + tmpsig="$(mktemp "${tmp_prefix}.sig.XXXXXXXX")" + trap 'gpgconf --homedir "$gnupg_home" --kill all >/dev/null 2>&1 || true; rm -rf "$gnupg_home" "$tmpscript" "$tmpsig"' EXIT + chmod 700 "$gnupg_home" + export GNUPGHOME="$gnupg_home" + + if ! gpg_import_release_key; then + unsigned_install_or_fail "failed to fetch/import/verify mise release key from ${MISE_GPG_KEY_URL}" + elif ! curl_fetch -o "$tmpsig" "$MISE_SIG_URL" \ + || ! gpg --batch --no-tty --decrypt "$tmpsig" >"$tmpscript"; then + unsigned_install_or_fail "failed to fetch/verify ${MISE_SIG_URL}" + else + MISE_VERSION="$MISE_VERSION" sh "$tmpscript" + fi +else + unsigned_install_or_fail "gpg not available" +fi + +# Make the freshly-installed binary discoverable to this script's own +# verification below: the unsigned (mise.run) and signed installers default to +# ${HOME}/.local/bin, which may not be on PATH yet. This export only affects +# this process -- `bash install-mise.sh` runs in a subprocess, so it cannot +# update the caller's PATH. Callers that need mise after this returns must +# update their own PATH (container RUN layers already set ENV PATH). +export PATH="${HOME}/.local/bin:${PATH}" + +if ! command -v mise >/dev/null 2>&1; then + echo "ERROR: mise not found after install" >&2 + exit 1 +fi + +installed="$(current_mise_version)" + +if [[ "$installed" != "$expected" ]]; then + echo "ERROR: installed mise ${installed} does not match pinned ${MISE_VERSION}" >&2 + exit 1 +fi + +echo "mise ${installed} installed successfully" diff --git a/tools/measurement/README.md b/tools/measurement/README.md index ca348649..628e941a 100644 --- a/tools/measurement/README.md +++ b/tools/measurement/README.md @@ -135,16 +135,13 @@ measurement JSONL format, one raw file per benchmark case plus a combined `measurements.jsonl`. ```bash -uv run python tools/measurement/run_benchmarks.py suite.yaml --output benchmark-runs/suite -uv run python tools/measurement/run_benchmarks.py suite.yaml --dry-run --json -uv run python tools/measurement/run_benchmarks.py suite.yaml \ - --output benchmark-runs/suite \ - --dd-trace last_message -uv run python tools/measurement/run_benchmarks.py suite.yaml \ - --output benchmark-runs/suite \ - --dd-task-trace +mise run benchmark smoke +mise run benchmark smoke -- --dry-run --json +mise run benchmark smoke-traces ``` +`smoke` runs `repo-data-smoke.yaml`. `smoke-traces` adds `--dd-trace last_message` and `--dd-task-trace`. Both profiles load `.env.local`, so endpoint credentials stay out of suite files. Set `BENCHMARK_OUTPUT_DIR` in `.env.local` to override the profile's default output directory. + The repo-data smoke suite can be run with DataDesigner traces enabled: ```bash diff --git a/tools/measurement/analyze_detection_artifacts.py b/tools/measurement/analyze_detection_artifacts.py index 42d84a96..b866df71 100644 --- a/tools/measurement/analyze_detection_artifacts.py +++ b/tools/measurement/analyze_detection_artifacts.py @@ -19,8 +19,9 @@ import re import sys from collections import Counter +from collections.abc import Iterable from pathlib import Path -from typing import Annotated, Iterable, Protocol, TypeGuard, cast +from typing import Annotated, Protocol, TypeGuard, cast import cyclopts import pandas as pd diff --git a/tools/measurement/create_wandb_report.py b/tools/measurement/create_wandb_report.py index b508d5af..b93790a5 100644 --- a/tools/measurement/create_wandb_report.py +++ b/tools/measurement/create_wandb_report.py @@ -724,7 +724,8 @@ def _save_report(report: Any, *, draft: bool) -> Any: """Save a report, falling back around a W&B SDK project-list auth edge.""" try: return report.save(draft=draft) - except Exception as exc: # noqa: BLE001 -- preserve the SDK error as fallback context + # Preserve the SDK error as fallback context. + except Exception as exc: if "relogin required" not in str(exc).lower(): raise logger.info("Falling back to direct W&B report upsert after project preflight auth failure.") diff --git a/tools/measurement/measurement_tools/tables.py b/tools/measurement/measurement_tools/tables.py index a77ec2ae..be3f5a43 100644 --- a/tools/measurement/measurement_tools/tables.py +++ b/tools/measurement/measurement_tools/tables.py @@ -5,10 +5,10 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from enum import StrEnum from pathlib import Path -from typing import Sequence import pandas as pd from pydantic import BaseModel, Field diff --git a/tools/measurement/measurement_tools/wandb_metric_schema.py b/tools/measurement/measurement_tools/wandb_metric_schema.py index 6bda017a..14ed7d13 100644 --- a/tools/measurement/measurement_tools/wandb_metric_schema.py +++ b/tools/measurement/measurement_tools/wandb_metric_schema.py @@ -4,10 +4,10 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field from enum import StrEnum from types import MappingProxyType -from typing import Mapping from anonymizer.measurement.fields import ( SCALAR_ADDITIVE_FIELDS, diff --git a/tools/measurement/measurement_tools/wandb_setup.py b/tools/measurement/measurement_tools/wandb_setup.py index 456d839e..a09d2099 100644 --- a/tools/measurement/measurement_tools/wandb_setup.py +++ b/tools/measurement/measurement_tools/wandb_setup.py @@ -10,10 +10,10 @@ import stat import sys import threading -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable +from typing import Any from urllib.parse import quote from measurement_tools.wandb_ingress import read_measurement_snapshot @@ -77,7 +77,7 @@ class BenchmarkWandbFinalization: def require_wandb() -> Any: """Import wandb inside the publisher's guarded SDK environment.""" - global _PUBLISHER_WANDB_MODULE # noqa: PLW0603 + global _PUBLISHER_WANDB_MODULE if _WANDB_ENVIRONMENT_OWNER != threading.get_ident(): raise RuntimeError("wandb must be imported inside the guarded SDK environment") @@ -129,7 +129,7 @@ def __init__(self, settings: ResolvedWandbConfig) -> None: self._snapshot: dict[str, str] | None = None def __enter__(self) -> WandbSdkEnvironment: - global _WANDB_ENVIRONMENT_OWNER # noqa: PLW0603 + global _WANDB_ENVIRONMENT_OWNER if not _WANDB_ENVIRONMENT_LOCK.acquire(blocking=False): raise RuntimeError("nested or concurrent W&B publisher use is not allowed") @@ -153,7 +153,7 @@ def __exit__(self, _exc_type: Any, _exc: Any, _traceback: Any) -> None: self._restore() def _restore(self) -> None: - global _WANDB_ENVIRONMENT_OWNER # noqa: PLW0603 + global _WANDB_ENVIRONMENT_OWNER if self._snapshot is None: return @@ -301,7 +301,8 @@ def publish_benchmark_wandb_best_effort( finalization=finalization, metadata=resolved_metadata, ) - except Exception as exc: # noqa: BLE001 -- native observability is explicitly best-effort + # Native observability is explicitly best-effort. + except Exception as exc: logger.warning("Failed to publish benchmark measurements to W&B (%s)", type(exc).__name__) return WandbPublishResult(published=False) @@ -314,7 +315,7 @@ def _build_publish_payload( finalization: BenchmarkWandbFinalization, metadata: WandbRunMetadata | None, ) -> tuple[WandbPublishPayload, str, int]: - from measurement_tools.wandb_logging import build_outbound_measurements # noqa: PLC0415 + from measurement_tools.wandb_logging import build_outbound_measurements cases = list(finalization.cases) expected_statuses = {str(case.case_id): str(case.status.value) for case in cases} @@ -558,5 +559,6 @@ def _define_benchmark_metrics(run: Any) -> None: for metric_name in ("benchmark/*", "measurement/*"): try: define_metric(metric_name, summary="last") - except Exception as exc: # noqa: BLE001 -- presentation polish is best-effort + # Metric definitions are presentation polish and must not block publishing. + except Exception as exc: logger.warning("Failed to define W&B metric %s (%s)", metric_name, type(exc).__name__) diff --git a/tools/measurement/sweep_benchmarks.py b/tools/measurement/sweep_benchmarks.py index be6c7c11..e6d546a3 100644 --- a/tools/measurement/sweep_benchmarks.py +++ b/tools/measurement/sweep_benchmarks.py @@ -15,9 +15,10 @@ import itertools import logging import sys +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Any, Callable, cast +from typing import Annotated, Any, cast import cyclopts import run_benchmarks @@ -40,7 +41,7 @@ class SweepSpec(BaseModel): parameters: dict[str, list[Any]] = Field(min_length=1) @model_validator(mode="after") - def validate_parameters(self) -> "SweepSpec": + def validate_parameters(self) -> SweepSpec: empty = [name for name, values in self.parameters.items() if not values] if empty: raise ValueError(f"sweep parameter(s) must have at least one value: {', '.join(sorted(empty))}") @@ -330,7 +331,8 @@ def _run_arm( fail_fast=fail_fast, wandb_settings=arm_wandb_settings, ) - except Exception as exc: # noqa: BLE001 -- keep sweeping other arms and report failure + # Keep sweeping other arms and report this failure in the result set. + except Exception as exc: return _arm_error( arm, suite_path=suite_path, @@ -439,7 +441,8 @@ def _planned_case_count_if_readable(suite_path: Path) -> int: return 0 try: return _planned_case_count(suite_path) - except Exception: # noqa: BLE001 -- keep the original per-arm failure as the reported error + # Keep the original per-arm failure as the reported error. + except Exception: return 0 @@ -466,7 +469,8 @@ def _maybe_create_view( group=wandb_settings.wandb_group or spec.sweep_id, expected_run_kind="sweep_arm", ) - except Exception as exc: # noqa: BLE001 -- remote SDK errors must not expose response contents + # Remote SDK errors must not expose response contents. + except Exception as exc: raise WandbViewCreationError(f"W&B {operation.label} creation failed ({type(exc).__name__})") from None return getattr(result, operation.url_attribute) diff --git a/uv.lock b/uv.lock index 1cdea812..b487e069 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -2514,7 +2514,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.0.0,<5" }, { name = "pytest", specifier = ">=9.0.3,<10" }, { name = "pytest-cov", specifier = ">=7.0,<8" }, - { name = "ruff", specifier = ">=0.12.3,<1" }, + { name = "ruff", specifier = "==0.16.2" }, { name = "ty", specifier = "==0.0.69" }, { name = "wandb", extras = ["workspaces"], specifier = ">=0.19,<1" }, ] @@ -3942,27 +3942,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]]