Skip to content
Draft
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
168 changes: 168 additions & 0 deletions .github/scripts/coverage_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Parse Cobertura XML coverage reports and render a Markdown summary.

Usage:
coverage_report.py [LABEL=]PATH [[LABEL=]PATH ...]

Each positional argument is the path to a Cobertura XML file, optionally
prefixed with a label (e.g. ``Python=coverage.xml``). Missing files are
skipped. The report is written to stdout and starts with a marker comment so
that a bot can find and update a single PR comment.
"""

from __future__ import annotations

import argparse
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

MARKER = "<!-- pcms-coverage-report -->"
MAX_FILES_PER_SECTION = 200


def _to_float(value):
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None


def _to_int(value):
number = _to_float(value)
return int(number) if number is not None else None


def _fmt_counts(covered, valid):
"""Format a percentage with absolute counts, or an em dash if unknown."""
if valid is None or valid <= 0:
return "\u2014"
covered = covered or 0
return f"{100.0 * covered / valid:.1f}% ({covered:,}/{valid:,})"


def _fmt_rate(rate, covered, valid):
"""Format a 0..1 rate, preferring absolute counts when available."""
if rate is None:
return "\u2014"
if valid is not None and valid > 0 and covered is not None:
return _fmt_counts(covered, valid)
return f"{100.0 * rate:.1f}%"


def _parse(path):
root = ET.parse(path).getroot()

def counts(prefix):
return (
_to_int(root.get(f"{prefix}-covered")),
_to_int(root.get(f"{prefix}-valid")),
)

lines_covered, lines_valid = counts("lines")
branches_covered, branches_valid = counts("branches")

classes = []
for cls in root.iter("class"):
filename = cls.get("filename") or cls.get("name") or "?"
classes.append(
{
"filename": filename,
"line_rate": _to_float(cls.get("line-rate")),
"branch_rate": _to_float(cls.get("branch-rate")),
"line_covered": _to_int(cls.get("lines-covered")),
"line_valid": _to_int(cls.get("lines-valid")),
"branch_covered": _to_int(cls.get("branches-covered")),
"branch_valid": _to_int(cls.get("branches-valid")),
}
)

return {
"lines_covered": lines_covered,
"lines_valid": lines_valid,
"branches_covered": branches_covered,
"branches_valid": branches_valid,
"has_branch": branches_valid is not None and branches_valid > 0,
"classes": classes,
}


def _label_for(path):
name = path.name.lower()
if "cpp" in name or "c++" in name:
return "C++"
if name.startswith("coverage"):
return "Python"
return path.stem


def main(argv=None):
parser = argparse.ArgumentParser(description="Render Cobertura XML as Markdown.")
parser.add_argument("inputs", nargs="*", help="[LABEL=]PATH to a Cobertura XML file")
args = parser.parse_args(argv)

reports = []
for spec in args.inputs:
if "=" in spec:
label, raw_path = spec.split("=", 1)
else:
label, raw_path = None, spec
path = Path(raw_path)
if not path.is_file():
print(f"warning: skipping missing coverage file: {path}", file=sys.stderr)
continue
label = label or _label_for(path)
reports.append((label, _parse(path)))

lines = [MARKER, "", "## Code Coverage Report", ""]

if not reports:
lines.append("_No coverage data was found for this run._")
print("\n".join(lines))
return 0

lines.append("| Source | Line Coverage | Branch Coverage |")
lines.append("|---|---|---|")
for label, report in reports:
lines.append(
f"| {label} | {_fmt_counts(report['lines_covered'], report['lines_valid'])} "
f"| {_fmt_counts(report['branches_covered'], report['branches_valid'])} |"
)
lines.append("")

for label, report in reports:
classes = sorted(
report["classes"],
key=lambda c: (c["line_rate"] if c["line_rate"] is not None else 1.0, c["filename"]),
)
if not classes:
continue
lines.append("<details>")
lines.append(f"<summary>{label}: {len(classes)} files</summary>")
lines.append("")
lines.append("| File | Line Coverage | Branch Coverage |")
lines.append("|---|---|---|")
for cls in classes[:MAX_FILES_PER_SECTION]:
branch_cell = (
_fmt_rate(cls["branch_rate"], cls["branch_covered"], cls["branch_valid"])
if report["has_branch"]
else "\u2014"
)
lines.append(
f"| `{cls['filename']}` | {_fmt_rate(cls['line_rate'], cls['line_covered'], cls['line_valid'])} "
f"| {branch_cell} |"
)
if len(classes) > MAX_FILES_PER_SECTION:
lines.append(f"| _... {len(classes) - MAX_FILES_PER_SECTION} more files omitted_ | | |")
lines.append("")
lines.append("</details>")
lines.append("")

print("\n".join(lines).rstrip())
return 0


if __name__ == "__main__":
sys.exit(main())
62 changes: 60 additions & 2 deletions .github/workflows/cmake-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
test-build:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read

defaults:
run:
Expand All @@ -29,6 +31,7 @@ jobs:
python_api: [OFF, ON]
meshfields: [ON]
petsc: [ON]
coverage: [OFF]
include:
- build_type: Release
memory_test: OFF
Expand All @@ -44,6 +47,14 @@ jobs:
python_api: OFF
meshfields: ON
petsc: OFF
- build_type: Coverage
memory_test: OFF
compiler: g++
language: 'cpp'
python_api: ON
meshfields: ON
petsc: ON
coverage: ON
exclude:
- build_type: Release
memory_test: ON
Expand All @@ -67,7 +78,7 @@ jobs:
if: matrix.python_api == 'ON'
run: |
sudo apt-get install -yq python3 python3-pip python3-dev python3-pybind11 pybind11-dev
pip3 install numpy pytest
pip3 install numpy pytest pytest-cov

- uses: actions/checkout@v4

Expand Down Expand Up @@ -289,7 +300,54 @@ jobs:
export PYTHONPATH=${{ runner.temp }}/build-pcms/install/lib/python3.12/site-packages:$PYTHONPATH
export PYTHONPATH=${{ runner.temp }}/build-omega_h/install/lib/python/dist-packages:$PYTHONPATH
cd ${{ github.workspace }}
pytest -v --tb=short -p no:cacheprovider
pytest -v --tb=short -p no:cacheprovider --cov=. --cov-report=term-missing --cov-report=xml

- name: Install gcovr and generate C++ coverage
if: matrix.coverage == 'ON'
run: |
pip3 install gcovr
gcovr -r ${{ github.workspace }}/pcms \
--object-directory=${{ runner.temp }}/build-pcms \
--gcov-ignore-parse-errors=suspicious_hits.warn \
--xml coverage-cpp.xml \
--print-summary

- name: Print coverage report
if: matrix.coverage == 'ON'
run: |
python3 .github/scripts/coverage_report.py coverage.xml coverage-cpp.xml > /tmp/coverage-report.md
cat /tmp/coverage-report.md
cat /tmp/coverage-report.md >> "$GITHUB_STEP_SUMMARY"

- name: Save PR number
if: github.event_name == 'pull_request' && matrix.coverage == 'ON'
run: |
mkdir -p pr-context
printf '%s' "${{ github.event.number }}" > pr-context/pr_number

- name: Upload PR number
if: github.event_name == 'pull_request' && matrix.coverage == 'ON'
uses: actions/upload-artifact@v4
with:
name: pr-number
path: pr-context
retention-days: 1

- name: Upload Python coverage report
if: matrix.coverage == 'ON'
uses: actions/upload-artifact@v4
with:
name: coverage-python
path: coverage.xml
retention-days: 1

- name: Upload C++ coverage report
if: matrix.coverage == 'ON'
uses: actions/upload-artifact@v4
with:
name: coverage-cpp
path: coverage-cpp.xml
retention-days: 1

- name: Test PCMS Installation
if: matrix.python_api == 'OFF'
Expand Down
81 changes: 81 additions & 0 deletions .github/workflows/coverage-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Updates a single "code coverage" comment on PRs whenever the Test-Build
# workflow completes successfully.
#
# This is triggered via workflow_run (rather than pull_request) so that it runs
# in the base repository context and can write to pull requests from forks.
# Keep the `workflows:` name below in sync with the `name:` in cmake-test.yml.
name: Coverage Comment

on:
workflow_run:
workflows: ["Test-Build"]
types: [completed]

permissions:
actions: read
contents: read
issues: write

jobs:
coverage-comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Download PR number
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: pr-number
path: artifacts/pr-number
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Download Python coverage
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: coverage-python
path: artifacts/coverage-python
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Download C++ coverage
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: coverage-cpp
path: artifacts/coverage-cpp
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Generate coverage report
run: |
python3 .github/scripts/coverage_report.py \
artifacts/coverage-python/coverage.xml \
artifacts/coverage-cpp/coverage-cpp.xml \
> coverage-summary.md
cat coverage-summary.md >> "$GITHUB_STEP_SUMMARY"

- name: Post or update PR comment
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
PR_NUMBER="$(cat artifacts/pr-number/pr_number 2>/dev/null || true)"
if [ -z "$PR_NUMBER" ]; then
echo "No PR number artifact found; skipping PR comment."
exit 0
fi

COMMENT_ID="$(gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" \
--jq '[.[] | select(.body | contains("<!-- pcms-coverage-report -->"))][0].id // empty')"

if [ -n "$COMMENT_ID" ]; then
gh api -X PATCH "repos/$GH_REPO/issues/comments/$COMMENT_ID" \
-f body="$(cat coverage-summary.md)"
else
gh pr comment "$PR_NUMBER" --body-file coverage-summary.md
fi
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ set(PCMS_HAS_ASAN OFF)
if(PCMS_ENABLE_ASAN AND CMAKE_COMPILER_IS_GNUCXX MATCHES 1)
set(PCMS_HAS_ASAN ON)
endif()
# Code coverage support
if(PROJECT_IS_TOP_LEVEL AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
set(CMAKE_CXX_FLAGS_COVERAGE "-g -O0 --coverage")
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
string(APPEND CMAKE_CXX_FLAGS_COVERAGE " -fprofile-abs-path")
endif()
set(CMAKE_EXE_LINKER_FLAGS_COVERAGE "--coverage")
set(CMAKE_SHARED_LINKER_FLAGS_COVERAGE "--coverage")
set(CMAKE_MODULE_LINKER_FLAGS_COVERAGE "--coverage")
endif()

option(PCMS_ENABLE_CLANG_TIDY "enable clang tidy target" OFF)

# based on Professional CMake: A Practical Guide
Expand Down
Loading