diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8b3b1c..58e6d91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,10 @@ jobs: test: runs-on: ubuntu-latest + permissions: + contents: read + # Needed to post/update the coverage report comment on the PR. + pull-requests: write steps: - uses: actions/checkout@v4 with: @@ -73,10 +77,31 @@ jobs: # to derive the version; the default shallow checkout doesn't # include enough history for that. fetch-depth: 0 - - name: Build test image - run: docker compose --profile test build --no-cache test - - name: Run tests - run: docker compose --profile test run --rm test + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Run tests and diff-cover + run: bash scripts/run_coverage.sh + # Render coverage + test results as a single sticky PR comment. Runs even + # when diff-cover fails the gate above, so the report is always posted; the + # coverage.xml and junit.xml it reads are written before diff-cover runs. + - name: Coverage report comment + if: ${{ always() && github.event_name == 'pull_request' }} + uses: MishaKav/pytest-coverage-comment@v1.10.0 + with: + pytest-coverage-path: pytest-coverage.txt + junitxml-path: junit.xml + title: Coverage report + # Post diff-cover's own report as a second sticky comment. This is the one + # that explains a red check: it shows the changed-line coverage and the + # specific uncovered lines that fell below the threshold. run_coverage.sh + # writes diff-cover-report.md before the gate exits. + - name: Diff coverage comment + if: ${{ always() && github.event_name == 'pull_request' }} + uses: marocchino/sticky-pull-request-comment@v3.0.5 + with: + header: diff-coverage + path: diff-cover-report.md + ignore_empty: true test-js: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ea867e7..a7ba79e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ pip-log.txt .coverage .coverage-js .coverage.xml +diff-cover-report.md +junit.xml +pytest-coverage.txt .tox test-results/ diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 254f2fe..733e0e9 100755 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -87,7 +87,14 @@ COPY README.md README.md COPY .git .git COPY sparkmeter sparkmeter -RUN uv sync --locked --no-dev +# Supply the version explicitly when the build context has no usable .git — +# e.g. a git worktree, whose .git is a file pointing outside the context, so +# hatch-vcs/setuptools-scm cannot derive the version. Empty (the default) +# leaves hatch-vcs to read .git as usual. sparkmeter is the only source build +# in this step, so the global (non-dist-scoped) variable is unambiguous here. +ARG SETUPTOOLS_SCM_PRETEND_VERSION="" +RUN SETUPTOOLS_SCM_PRETEND_VERSION="$SETUPTOOLS_SCM_PRETEND_VERSION" \ + uv sync --locked --no-dev RUN apk del .build-deps diff --git a/scripts/run_coverage.sh b/scripts/run_coverage.sh new file mode 100644 index 0000000..ab8a265 --- /dev/null +++ b/scripts/run_coverage.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Build the test image, run the suite, and gate the changed lines with +# diff-cover. Coverage is produced inside the container and streamed out to the +# host, where git history is available to diff against the compare branch. +set -euo pipefail + +compare_branch="${DIFF_COVER_COMPARE_BRANCH:-origin/main}" +fail_under="${DIFF_COVER_FAIL_UNDER:-90}" + +# A git worktree's .git is a file, not a directory, and is unreadable inside the +# build context, so hatch-vcs cannot derive the version. Supply a placeholder in +# that case; a normal checkout leaves it empty and reads .git as usual. +version="" +if [ -f .git ]; then + version="0.0.0" +fi + +docker compose --profile test build \ + --build-arg SETUPTOOLS_SCM_PRETEND_VERSION="$version" \ + test + +# pytest writes .coverage.xml (see setup.cfg addopts) and, via --junitxml, a +# junit report; its terminal output (the term-missing coverage table) is teed to +# pytest-coverage.txt for the PR report comment. All three are produced in the +# container, tarred to stdout, and unpacked on the host. pytest's output is teed +# to stderr, not stdout, so stdout carries only the tar stream. +docker compose --profile test run --rm test \ + sh -c 'uv run pytest -n auto --junitxml=/app/junit.xml 2>&1 | tee /app/pytest-coverage.txt 1>&2; tar -c -C /app .coverage.xml junit.xml pytest-coverage.txt' \ + | tar -x + +# diff-cover gates the changed lines and always prints a terminal report. It +# also writes a markdown report, which is appended to the GitHub Actions job +# summary below. Under Actions it additionally emits GitHub annotations on +# stdout, so each uncovered changed line is flagged inline on the PR diff. +report_md="diff-cover-report.md" +formats="markdown:$report_md" +if [ -n "${GITHUB_ACTIONS:-}" ]; then + formats="$formats,github-annotations:error" +fi + +# --fail-under exits non-zero when changed-line coverage is below the threshold. +# Capture that instead of aborting so the report still reaches the job summary. +set +e +uvx "diff-cover==10.3.0" .coverage.xml \ + --compare-branch "$compare_branch" \ + --fail-under "$fail_under" \ + --format "$formats" +diff_cover_rc=$? +set -e + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$report_md" ]; then + cat "$report_md" >> "$GITHUB_STEP_SUMMARY" +fi + +exit "$diff_cover_rc" diff --git a/scripts/validatecoverage b/scripts/validatecoverage deleted file mode 100755 index 7743144..0000000 --- a/scripts/validatecoverage +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python -# -*- python -*- -import sys -from collections import OrderedDict -from xml.etree.ElementTree import parse - -# This is a list of files that doesn't have 100% coverage -# -# You will need to update this list when: -# - a new file was added without 100% coverage -# - new lines where added that weren't tested -# - 100% test coverage was reached for some file -# - a file got remove/renamed - -EXCEPTIONS_PYTHON = OrderedDict(( - ('sparkmeter/alembic/versions/0.32_merge_sms_tables.py', 93.48), - ('sparkmeter/api/historyviews0.py', 18.00), - ('sparkmeter/app.py', 97.87), - ('sparkmeter/conftest.py', 97.89), - ('sparkmeter/controller.py', 43.72), - ('sparkmeter/debug_memory.py', 0.00), - ('sparkmeter/history/historyview.py', 50.00), - ('sparkmeter/managecommands.py', 0.0), - ('sparkmeter/sentry_proxy.py', 71.43), - ('sparkmeter/sparkmac/bms.py', 70), - ('sparkmeter/sparkmac/jsonpacket.py', 71.35), - ('sparkmeter/sparkmac/slipstreamj.py', 17.43), - ('sparkmeter/transaction/transactionview.py', 99.17), - ('sparkmeter/web/unittestutils.py', 98.76), -)) - -EXCEPTIONS_JS = OrderedDict(( - ('assets/javascripts/backend.js', 56.10), - ('assets/javascripts/datatables.js', 84.62), - ('assets/javascripts/modal-item-service.js', 24.32), - ('assets/javascripts/startup.js', 58.93), - ('sparkmeter/config/js/config-pages.js', 84.00), - ('sparkmeter/config/js/sms.directives.js', 14.29), - ('sparkmeter/config/js/smsconfigmessages.controller.js', 15.38), - ('sparkmeter/config/js/smsconfigmessages.js', 9.38), - ('sparkmeter/config/js/smseventtypes.js', 65.22), - ('sparkmeter/config/js/smstemplate.js', 3.64), - ('sparkmeter/dashboard/js/dashboard-pages.js', 50.00), - ('sparkmeter/event/js/event-pages.js', 50.00), - ('sparkmeter/ground/js/ground-pages.js', 27.78), - ('sparkmeter/homepage/js/homepage.js', 20.00), - ('sparkmeter/meter/js/meter-pages.js', 33.33), - ('sparkmeter/meter/js/meter-view.js', 49.12), - ('sparkmeter/reading/js/reading-pages.js', 50.00), - ('sparkmeter/salesaccount/js/sales-account-list.js', 93.18), - ('sparkmeter/salesaccount/js/salesaccount-pages.js', 42.86), - ('sparkmeter/tariff/js/tariff-domain.js', 3.70), - ('sparkmeter/tariff/js/tariff-form.js', 7.69), - ('sparkmeter/tariff/js/tariff-pages.js', 40.00), - ('sparkmeter/transaction/js/transaction-pages.js', 38.46), - ('sparkmeter/user/js/user-pages.js', 33.33), -)) - -# Make sure that the list of exceptions is sorted -for e in [EXCEPTIONS_JS, EXCEPTIONS_PYTHON]: - for a, b in zip(e, sorted(e)): - if a != b: - raise AssertionError("keep EXCEPTIONS_JS & EXCEPTIONS_PYTHON sorted") - -_errors = [] - -def _error(msg): - _errors.append(msg) - -tool = "scripts/validatecoverage" - -if sys.argv[1] == '--python': - exceptions = EXCEPTIONS_PYTHON -elif sys.argv[1] == '--js': - exceptions = EXCEPTIONS_JS -else: - raise SystemExit('first argument must be --js or --python') -root = parse(open(sys.argv[2])) -fixes = [] -modules = {} - -for module in sorted(root.findall('.//class')): - filename = module.attrib['filename'] - if filename.endswith('__init__.py'): - continue - modules[filename] = module - current = float(module.attrib.get('line-rate')) * 100 - if current != 100 and filename not in exceptions: - _error("2: %s has a coverage of %s%%, but expected 100%%, " - "add it to %s:" % ( - filename, current, tool)) - fixes.append(" ('%s', %.2f)," % (filename, current)) - continue - - expect = exceptions.get(filename, 100) - if current == 100 and expect == 100: - continue - - if int(current + 1) < int(expect): - _error("1: %s has a coverage of %.2f%%, but expected %.2f%%, " - "add more tests or update %s" % ( - filename, float(current), float(expect), tool)) - continue - if current == 100 and expect < 100: - _error("3: %s reached 100%%, remove it from %s" % ( - filename, tool)) - continue - -for filename in exceptions: - if not filename in modules: - _error("4: %s not found, remove it from %s" % ( - filename, tool)) - continue - -for error in sorted(_errors): - print 'ERROR:', error - -if fixes: - print "FIXES:" - for fix in sorted(fixes): - print fix - -sys.exit(len(_errors)) diff --git a/setup.cfg b/setup.cfg index 0f3d5e6..490c5ba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,7 @@ python_classes = *Test Test* output=.coverage.xml [coverage:run] +relative_files = True omit = **/test_*.py sparkmeter/tests/base.py