diff --git a/.github/bin/merge_rust_coverage.py b/.github/bin/merge_rust_coverage.py new file mode 100644 index 000000000000..e49f4232c9cf --- /dev/null +++ b/.github/bin/merge_rust_coverage.py @@ -0,0 +1,97 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import collections +import sys + +import coverage + + +class RustCoveragePlugin(coverage.CoveragePlugin): + def file_reporter(self, filename: str): + return RustCoverageFileReporter(filename) + + +class RustCoverageFileReporter(coverage.FileReporter): + def lines(self) -> set[int]: + # XXX: Need a better way to handle this state! + return set(raw_data[self.filename]) + + +def coverage_init( + reg: coverage.plugin_support.Plugins, + options: coverage.types.TConfigSectionOut, +) -> None: + reg.add_file_tracer(RustCoveragePlugin()) + + +def main(*lcov_paths: str): + cov = coverage.Coverage() + # XXX: Nasty mucking in semi-public APIs + cov.config.plugins.append("coverage_rust_plugin") + sys.modules["coverage_rust_plugin"] = sys.modules[__name__] + + coverage_data = coverage.CoverageData(suffix="rust") + + # XXX: global state! Bad! + global raw_data + # {filename: {line_number: count}} + raw_data = collections.defaultdict(lambda: collections.defaultdict(int)) + current_file = None + for p in lcov_paths: + with open(p) as f: + for line in f: + line = line.strip() + if line == "end_of_record": + assert current_file is not None + current_file = None + continue + + prefix, suffix = line.split(":", 1) + match prefix: + case "SF": + current_file = raw_data[suffix] + case "DA": + assert current_file is not None + line_number, count = suffix.split(",") + current_file[int(line_number)] += int(count) + case ( + "BRF" + | "BRH" + | "FN" + | "FNDA" + | "FNF" + | "FNH" + | "LF" + | "LH" + ): + # These are various forms of metadata and summary stats + # that we don't need. + pass + case _: + raise NotImplementedError(prefix) + + covered_lines = { + file_name: [line for line, c in lines.items() if c > 0] + for file_name, lines in raw_data.items() + } + coverage_data.add_lines(covered_lines) + coverage_data.add_file_tracers( + { + file_name: "coverage_rust_plugin.RustCoveragePlugin" + for file_name in covered_lines + } + ) + coverage_data.write() + + cov.combine() + coverage_percent = cov.report(show_missing=True) + if coverage_percent < 100: + print("+++ Python coverage under 100% +++") + cov.html_report() + sys.exit(1) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57240787d843..f555a025daa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -449,50 +449,21 @@ jobs: id: combinecoverage run: | set +e - python -m coverage combine - echo "## Python Coverage" >> $GITHUB_STEP_SUMMARY - python -m coverage report -m --fail-under=100 > COV_REPORT + echo "## Coverage" >> $GITHUB_STEP_SUMMARY + python .github/bin/merge_rust_coverage.py *.lcov > COV_REPORT COV_EXIT_CODE=$? cat COV_REPORT if [ $COV_EXIT_CODE -ne 0 ]; then - echo "🚨 Python Coverage failed. Under 100" | tee -a $GITHUB_STEP_SUMMARY + echo "🚨 Coverage failed. Under 100" | tee -a $GITHUB_STEP_SUMMARY fi - echo '```' >> $GITHUB_STEP_SUMMARY + echo "```" >> $GITHUB_STEP_SUMMARY cat COV_REPORT >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY + echo "```" >> $GITHUB_STEP_SUMMARY exit $COV_EXIT_CODE - - name: Combine rust coverage and fail if it's <100%. - if: ${{ always() }} - id: combinerustcoverage - run: | - set +e - sudo apt-get install -y lcov - RUST_COVERAGE_OUTPUT=$(lcov $(for f in *.lcov; do echo --add-tracefile "$f"; done) -o combined.lcov | grep lines) - echo "## Rust Coverage" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo $RUST_COVERAGE_OUTPUT >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - if ! echo "$RUST_COVERAGE_OUTPUT" | grep "100.0%"; then - echo "🚨 Rust Coverage failed. Under 100" | tee -a $GITHUB_STEP_SUMMARY - exit 1 - fi - - name: Create rust coverage HTML - run: genhtml combined.lcov -o rust-coverage - if: ${{ failure() && steps.combinerustcoverage.outcome == 'failure' }} - - name: Create coverage HTML - run: python -m coverage html - if: ${{ failure() && steps.combinecoverage.outcome == 'failure' }} - name: Upload HTML report. uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: - name: _html-report + name: _html-coverage-report path: htmlcov if-no-files-found: ignore if: ${{ failure() && steps.combinecoverage.outcome == 'failure' }} - - name: Upload rust HTML report. - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: _html-rust-report - path: rust-coverage - if-no-files-found: ignore - if: ${{ failure() && steps.combinerustcoverage.outcome == 'failure' }}