Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions .github/scripts/coverage_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Copyright (c) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
"""Compare line coverage against a baseline and render a GitHub Actions job summary.

Inputs are ``coverage json`` reports (current and optional baseline) and an optional
``diff-cover --format json`` report with coverage of the lines changed by a PR.

The script appends Markdown to ``$GITHUB_STEP_SUMMARY`` (or stdout), prints a ``::notice``
annotation with the headline numbers, and fails on exit when total line coverage dropped
below ``--fail-threshold`` percentage compared to the baseline.
"""

import argparse
import json
import os
import sys
from pathlib import Path


def emit(message: str) -> None:
"""Write a line to stdout, where GitHub Actions picks up workflow commands."""
sys.stdout.write(message + "\n")


def load_json(path: Path | None) -> dict | None:
"""Return parsed JSON from ``path`` or None when the file is not available."""
if path is None or not path.is_file():
return None
return json.loads(path.read_text(encoding="utf-8"))


def line_stats(summary: dict) -> tuple[int, int]:
"""Return ``(covered_lines, num_statements)`` from a coverage.py summary block."""
return summary["covered_lines"], summary["num_statements"]


def percent(covered: int, total: int) -> float:
"""Return coverage percentage; empty code counts as fully covered."""
return 100.0 * covered / total if total else 100.0


def file_percents(report: dict) -> dict[str, tuple[float, int, int]]:
"""Map each file to ``(percent, covered, statements)``."""
result = {}
for name, data in report["files"].items():
covered, total = line_stats(data["summary"])
result[name] = (percent(covered, total), covered, total)
return result


def fmt_delta(delta: float) -> str:
"""Format a percentage-point delta with an explicit sign."""
if abs(delta) < 0.005:
return "±0.00 %"
return f"{delta:+.2f} %"


def changed_files_table(current: dict, baseline: dict) -> list[str]:
"""Build a Markdown table of files whose line coverage differs from the baseline."""
cur = file_percents(current)
base = file_percents(baseline)
rows = []
for name in sorted(cur.keys() | base.keys()):
c, b = cur.get(name), base.get(name)
if c and b and abs(c[0] - b[0]) < 0.005:
continue
base_txt = f"{b[0]:.2f}% ({b[1]}/{b[2]})" if b else "—"
cur_txt = f"{c[0]:.2f}% ({c[1]}/{c[2]})" if c else "removed"
delta_txt = fmt_delta(c[0] - b[0]) if c and b else ("new" if c else "removed")
rows.append(f"| `{name}` | {base_txt} | {cur_txt} | {delta_txt} |")
if not rows:
return ["No per-file coverage changes."]
return [
"| File | Baseline | Current | Δ |",
"|------|---------:|--------:|--:|",
*rows,
]


def main() -> int:
"""Entry point."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--current", type=Path, required=True, help="coverage json report of this run")
parser.add_argument("--baseline", type=Path, help="coverage json report of the baseline (latest main)")
parser.add_argument("--baseline-label", default="main", help="label of the baseline, e.g. 'main @ abc1234'")
parser.add_argument("--diff-cover", type=Path, help="diff-cover json report of changed lines")
parser.add_argument("--diff-cover-markdown", type=Path, help="diff-cover markdown report to embed")
parser.add_argument(
"--fail-threshold", type=float, help="fail when total coverage drops by more than this percentage"
)
args = parser.parse_args()

current = load_json(args.current)
if current is None:
emit(f"::error title=Coverage::Coverage report {args.current} not found")
return 1
baseline = load_json(args.baseline)
diff = load_json(args.diff_cover)

cur_cov, cur_total = line_stats(current["totals"])
cur_pct = percent(cur_cov, cur_total)

lines = ["## Line coverage", ""]
lines += ["| | Baseline | Current | Δ |", "|---|---:|---:|--:|"]
notice = [f"Total line coverage: {cur_pct:.2f}%"]
delta = None
if baseline is not None:
base_cov, base_total = line_stats(baseline["totals"])
base_pct = percent(base_cov, base_total)
delta = cur_pct - base_pct
lines.append(
f"| **Total** ({args.baseline_label}) | {base_pct:.2f}% ({base_cov}/{base_total}) "
f"| **{cur_pct:.2f}%** ({cur_cov}/{cur_total}) | **{fmt_delta(delta)}** |"
)
notice[0] += f" ({fmt_delta(delta)} vs {args.baseline_label})"
else:
lines.append(f"| **Total** | no baseline | **{cur_pct:.2f}%** ({cur_cov}/{cur_total}) | — |")
notice[0] += " (no baseline available)"

if diff is not None:
diff_total = diff.get("total_num_lines", 0)
if diff_total:
diff_missed = diff.get("total_num_violations", 0)
diff_pct = float(diff.get("total_percent_covered", percent(diff_total - diff_missed, diff_total)))
lines.append(
f"| **Changed lines** (diff-cover) | — | **{diff_pct:.2f}%** "
f"({diff_total - diff_missed}/{diff_total}) | — |"
)
notice.append(f"changed lines: {diff_pct:.2f}% ({diff_total - diff_missed}/{diff_total})")
else:
lines.append("| **Changed lines** (diff-cover) | — | no measured lines changed | — |")
notice.append("changed lines: none measured")

fail_threshold = args.fail_threshold
failed = delta is not None and fail_threshold is not None and delta < -fail_threshold
if failed:
lines += [
"",
f"> [!CAUTION]\n> Total line coverage dropped by {-delta:.2f} %, "
f"which exceeds the allowed {fail_threshold:.2f} %.",
]

if baseline is not None:
lines += ["", "<details><summary>Per-file changes vs baseline</summary>", ""]
lines += changed_files_table(current, baseline)
lines += ["", "</details>"]

diff_md = args.diff_cover_markdown
if diff_md is not None and diff_md.is_file():
lines += ["", "<details><summary>diff-cover report</summary>", ""]
lines += [diff_md.read_text(encoding="utf-8").strip(), "", "</details>"]

lines.append("")
lines.append("**HTML coverage report** is available in the `coverage-report` artifact.")
lines.append("Download it, down below, and open `.\\htmlcov\\index.html` to view the report.")
lines.append("")

output = "\n".join(lines) + "\n"
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with open(summary_path, "a", encoding="utf-8") as f:
f.write(output)
else:
emit(output)

emit(f"::notice title=Coverage::{', '.join(notice)}")
if failed:
emit(f"::error title=Coverage::Total line coverage dropped by {-delta:.2f}% (threshold: {fail_threshold:.2f}%)")
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
145 changes: 136 additions & 9 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- "conftest.py"
- "uv.lock"
- ".github/workflows/tests.yml"
- ".github/scripts/coverage_summary.py"
pull_request:
paths: *tests-paths

Expand Down Expand Up @@ -42,6 +43,10 @@ jobs:
--device=/dev/dri
volumes:
- ${{ github.workspace }}:${{ github.workspace }}
env:
# Shared by tests and examples (which run in their own venvs), so all data lands in one place.
COVERAGE_RCFILE: ${{ github.workspace }}/torch-tweak/pyproject.toml
COVERAGE_FILE: ${{ github.workspace }}/torch-tweak/.coverage

steps:
- name: Checkout Torch Tweak repository
Expand Down Expand Up @@ -104,41 +109,163 @@ jobs:
working-directory: torch-tweak
run: |
# Uses [tool.pytest.ini_options] testpaths and --doctest-modules from pyproject.toml.
uv run pytest -v -m "not functional and not nightly"
uv run coverage run -m pytest -v -m "not functional and not nightly"

- name: Run functional tests
env:
ONEAPI_DEVICE_SELECTOR: level_zero:gpu
TORCH_TWEAK_TESTS_LOG_LEVEL: DEBUG
working-directory: torch-tweak
run: |
uv run pytest -m functional
uv run coverage run -m pytest -m functional

# Examples call their console scripts' modules directly, so coverage.py can wrap them.
- name: Run examples
env:
ONEAPI_DEVICE_SELECTOR: level_zero:gpu
working-directory: torch-tweak
run: |
echo "::group::minimal"
uv run python examples/Minimal/demo.py
uv run coverage run examples/Minimal/demo.py
echo "::endgroup::"

echo "::group::llm"
cd examples/LLM
uv sync
uv run tune --model_id microsoft/Phi-3-mini-4k-instruct
uv run benchmark --model_id microsoft/Phi-3-mini-4k-instruct --sequence_lengths "128,1024"
uv sync --group coverage
uv run coverage run -m llm.tune --model_id microsoft/Phi-3-mini-4k-instruct
uv run coverage run -m llm.benchmark --model_id microsoft/Phi-3-mini-4k-instruct --sequence_lengths "128,1024"
cd -
echo "::endgroup::"

echo "::group::e5large"
cd examples/E5Large
uv sync
uv run tune
uv run inference --prompt "query: What is the capital city of France?"
uv sync --group coverage
uv run coverage run -m e5large.tune
uv run coverage run -m e5large.inference --prompt "query: What is the capital city of France?"
cd -
echo "::endgroup::"

- name: Upload coverage data
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage-data
path: torch-tweak/.coverage.*
include-hidden-files: true
if-no-files-found: error
retention-days: 7

- name: Debug CI platform information
if: ${{ always() }}
run: ./torch-tweak/.github/scripts/get_system_info.sh

coverage:
name: Coverage
needs: tests
runs-on: ${{ github.repository_owner == 'intel' && 'intel-ubuntu-latest' || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
# Needed to find and download the baseline report from the latest successful run on main.
actions: read

steps:
- name: Checkout Torch Tweak repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# diff-cover needs the PR base commit.
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.x"

- name: Set up uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2

- name: Download coverage data
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: coverage-data
path: coverage-data

# Only coverage and diff-cover from uv.lock; the project itself and torch are not needed here.
- name: Install coverage tools
run: uv sync --locked --only-group coverage

- name: Combine coverage data and build reports
run: |
uv run --no-sync coverage combine coverage-data/
uv run --no-sync coverage json -o coverage.json
uv run --no-sync coverage xml -o coverage.xml
uv run --no-sync coverage html -d htmlcov
uv run --no-sync coverage report --skip-covered --sort=cover

# On main this artifact becomes the baseline for subsequent PRs.
- name: Upload coverage report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage-report
path: |
coverage.json
coverage.xml
htmlcov/
retention-days: 90

- name: Download baseline coverage from main
id: baseline
Comment thread
PatKamin marked this conversation as resolved.
env:
GH_TOKEN: ${{ github.token }}
run: |
read -r run_id head_sha < <(gh run list \
--repo "$GITHUB_REPOSITORY" --workflow tests.yml --branch main --event push --status success \
--limit 1 --json databaseId,headSha --jq '.[0] | "\(.databaseId) \(.headSha)"') || true
if [ -n "${run_id:-}" ] && gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \
--name coverage-report --dir baseline; then
echo "label=main @ ${head_sha:0:7}" >> "$GITHUB_OUTPUT"
else
echo "::warning title=Coverage::No baseline coverage report found on main"
fi

- name: Measure coverage of changed lines
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
uv run --no-sync diff-cover coverage.xml \
--compare-branch "$BASE_SHA" \
--include 'torch_tweak/**' \
--format json:diff-cover.json,markdown:diff-cover.md

# Threshold to fail PR if total line coverage drops by: 5%
- name: Publish coverage summary
id: summary
env:
BASELINE_LABEL: ${{ steps.baseline.outputs.label }}
FAIL_THRESHOLD_PARAM: ${{ github.event_name == 'pull_request' && '--fail-threshold 5' || '' }}
Comment thread
lukaszstolarczuk marked this conversation as resolved.
run: |
python .github/scripts/coverage_summary.py \
--current coverage.json \
--baseline baseline/coverage.json \
--baseline-label "${BASELINE_LABEL:-main}" \
--diff-cover diff-cover.json \
--diff-cover-markdown diff-cover.md \
${FAIL_THRESHOLD_PARAM}

# Export value of the code coverage %, rounded up to 2 decimal places
export COV_OUT=$(python -c 'import json; print(round(json.load(open("coverage.json"))["totals"]["percent_covered"], 2))')
echo "calculated coverage=${COV_OUT}\%"
echo "COV_OUT=$COV_OUT" >> $GITHUB_OUTPUT

# Only update the badge on push (event is passed only for total coverage)
- name: Update coverage badge
if: ${{ success() && github.event_name == 'push' }}
uses: Schneegans/dynamic-badges-action@28b0fa8bdeb46170ac397105ece0c1fe58f68910 # v1.9.0
with:
auth: ${{ secrets.BB_GIST_TOKEN }}
gistID: cfa745c6e219d84dec39841a981daea7
filename: tt_coverage_badge.svg
label: Coverage
message: ${{ steps.summary.outputs.COV_OUT }}%
valColorRange: ${{ steps.summary.outputs.COV_OUT }}
minColorRange: 50 # if <= this value = color: red
maxColorRange: 90 # if >= this value = color: green
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ environment variable to select the device, as CI does.
New features and bug fixes should come with tests. Place them under
'test/unit' or 'test/functional', respectively.

### Measuring coverage

CI measures line coverage of `torch_tweak` across unit tests, functional tests
and examples. The Tests workflow summary shows the total compared to the latest
`main` run, plus diff-cover coverage of the lines changed by the PR. A PR fails
if total coverage drops noticeably. To measure coverage locally:

```bash
uv run coverage run -m pytest -m "not functional and not nightly"
uv run coverage combine
uv run coverage report # or `coverage html` for a browsable report
```

## Building Packages

### Core library
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ NOTE: This file has been modified by Intel Corporation.
[![Tests](https://github.com/intel/torch-tweak/actions/workflows/tests.yml/badge.svg)](https://github.com/intel/torch-tweak/actions/workflows/tests.yml)
[![Nightly Tests](https://github.com/intel/torch-tweak/actions/workflows/nightly.yml/badge.svg?event=schedule)](https://github.com/intel/torch-tweak/actions/workflows/nightly.yml)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/intel/torch-tweak/badge)](https://securityscorecards.dev/viewer/?uri=github.com/intel/torch-tweak)
[![Coverage](https://gist.githubusercontent.com/bb-ur/cfa745c6e219d84dec39841a981daea7/raw/tt_coverage_badge.svg)](https://github.com/intel/torch-tweak/actions/workflows/tests.yml?query=branch%3Amain)

**Torch Tweak** is an inference toolkit designed for tuning and deploying Deep Learning models with a focus on Intel XPU (GPU) accelerators.
It provides model tuning capabilities through compilation and conversion paths that can significantly improve inference speed and efficiency
Expand Down
Loading
Loading