diff --git a/.github/scripts/python_coverage_summary.py b/.github/scripts/python_coverage_summary.py new file mode 100644 index 0000000..d3199c0 --- /dev/null +++ b/.github/scripts/python_coverage_summary.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Render a Cobertura-style coverage.xml (as produced by `coverage xml` / +pytest-cov's --cov-report=xml) as Markdown suitable for a GitHub Actions job +summary. + +Usage: + python3 python_coverage_summary.py + +Prints to stdout; the caller is expected to redirect into $GITHUB_STEP_SUMMARY. +Deliberately has no third-party dependencies so it can run with the stock +`python3` already available on GitHub-hosted runners -- no extra permissions +or installs are needed, which keeps it safe to run on pull requests from +forks. +""" + +import sys +import xml.etree.ElementTree as ET + + +def _pct(rate_attr): + try: + return float(rate_attr) * 100 + except (TypeError, ValueError): + return None + + +def _fmt_pct(value): + return "N/A" if value is None else f"{value:.1f}%" + + +def main(argv): + if len(argv) != 2: + print("Usage: python_coverage_summary.py ", file=sys.stderr) + return 2 + + path = argv[1] + + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError) as exc: + print("## Python test coverage") + print() + print(f"No coverage report found at `{path}` ({exc}).") + return 0 + + line_rate = _pct(root.get("line-rate")) + branch_rate = _pct(root.get("branch-rate")) + lines_covered = root.get("lines-covered", "?") + lines_valid = root.get("lines-valid", "?") + + print("## Python test coverage") + print() + print( + f"**Overall line coverage: {_fmt_pct(line_rate)}** " + f"({lines_covered}/{lines_valid} lines)  |  " + f"branch coverage: {_fmt_pct(branch_rate)}" + ) + print() + print("
Per-file coverage") + print() + print("| File | Line coverage | Lines covered |") + print("| --- | --- | --- |") + + classes = sorted(root.iter("class"), key=lambda c: c.get("filename", "")) + for cls in classes: + filename = cls.get("filename", "?") + file_line_rate = _pct(cls.get("line-rate")) + + total = covered = 0 + lines_elem = cls.find("lines") + if lines_elem is not None: + for line in lines_elem.findall("line"): + total += 1 + if int(line.get("hits", "0")) > 0: + covered += 1 + + print(f"| `{filename}` | {_fmt_pct(file_line_rate)} | {covered}/{total} |") + + print() + print("
") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/.github/workflows/build-wheel.yml b/.github/workflows/build-wheel.yml new file mode 100644 index 0000000..5e138fe --- /dev/null +++ b/.github/workflows/build-wheel.yml @@ -0,0 +1,66 @@ +name: Build Python Wheel + +on: + workflow_call: + inputs: + ref: + description: Git ref to check out + required: false + type: string + default: "" + +jobs: + build-wheel: + name: Build Python wheel + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || github.ref }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Build and test + run: ./gradlew clean build buildPythonWheel + + - name: Publish Python coverage summary + if: always() + run: | + python3 .github/scripts/python_coverage_summary.py \ + regi-headless/build/reports/coverage/coverage.xml \ + >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Python coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: python-coverage-html + path: regi-headless/build/reports/coverage/html + if-no-files-found: warn + retention-days: 14 + + - name: Collect wheel + run: | + mkdir -p dist + find . -path "*/build/install/*/dist/*.whl" -exec cp {} dist/ \; + + - name: Upload Python wheel artifact + uses: actions/upload-artifact@v4 + with: + name: python-wheel + path: dist/*.whl + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0be5339..c9f1b40 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,35 +9,18 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + build-wheel: name: Build and test - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Set up Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Make Gradle wrapper executable - run: chmod +x ./gradlew - - - name: Build and test - run: ./gradlew clean build + uses: ./.github/workflows/build-wheel.yml dependency-submission: name: Submit Gradle dependencies - needs: build + needs: build-wheel runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c0e6d6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +on: + release: + types: + - published + +permissions: + contents: read + actions: read + +concurrency: + group: release-${{ github.event.release.id }} + cancel-in-progress: false + +jobs: + build-wheel: + name: Build wheel from release tag + uses: ./.github/workflows/build-wheel.yml + with: + ref: ${{ github.event.release.tag_name }} + + publish-wheel: + name: Publish Python wheel to GitHub Release + needs: build-wheel + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download Python wheel artifact + uses: actions/download-artifact@v4 + with: + name: python-wheel + path: dist + + - name: Generate checksums + run: | + cd dist + sha256sum *.whl > SHA256SUMS.txt + + - name: Publish wheel to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.event.release.tag_name }}" dist/*.whl dist/SHA256SUMS.txt --clobber \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2e40c33..2367b78 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ *.iml .DS_STORE */build/ +.coverage* \ No newline at end of file diff --git a/regi-headless/build.gradle b/regi-headless/build.gradle index edc66ab..4e9d195 100644 --- a/regi-headless/build.gradle +++ b/regi-headless/build.gradle @@ -60,7 +60,7 @@ tasks.register('installPythonBuildTools', VenvTask) { description = 'Installs Python packages needed to build and test the wheel.' venvExec = 'pip' - args = ['install', '--upgrade', 'pip', 'build', 'pytest'] + args = ['install', '--upgrade', 'pip', 'build', 'pytest', 'pytest-cov'] outputs.file(layout.buildDirectory.file("python-build-tools/install.marker")) @@ -152,15 +152,25 @@ tasks.register('installPythonWheelForSmokeTest', VenvTask) { } tasks.register('testPythonWheel', VenvTask) { group = 'verification' - description = 'Runs pytest against the installed Python wheel.' + description = 'Runs pytest (with coverage) against the installed Python wheel.' dependsOn installPythonWheelForSmokeTest + def coverageDir = layout.buildDirectory.dir('reports/coverage') + venvExec = 'python' - args = ['-m', 'pytest', 'src/test/python'] + args = [ + '-m', 'pytest', 'src/test/python', + '--cov=regi_python', + '--cov-branch', + '--cov-report=term-missing', + "--cov-report=xml:${coverageDir.get().file('coverage.xml').asFile}", + "--cov-report=html:${coverageDir.get().dir('html').asFile}", + ] inputs.files(fileTree(dir: 'src/test/python', include: '**/*.py')) outputs.upToDateWhen { false } + outputs.dir(coverageDir) } check {