From fee8f568beeb72d8529e433ff8141533eb8122d4 Mon Sep 17 00:00:00 2001 From: runpod-Henrik Date: Tue, 25 Aug 2026 15:56:34 -0700 Subject: [PATCH 1/6] chore: render coverage numbers into the job summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage report was only readable by downloading the artifact. Write a Test Results + Coverage Summary table to $GITHUB_STEP_SUMMARY so the numbers show up on the run page, matching what the ai-api component workflow does. Adds --junitxml so the run's test counts can be reported alongside coverage; pytest-results.xml is gitignored. Stdlib only, so there is no extra install step, and `if: always()` means the summary still renders when tests fail — which is when it is most useful. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + Makefile | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b683c50a..55b9b2d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,62 @@ jobs: env: UV_PYTHON: ${{ matrix.python-version }} + - name: Coverage summary + if: always() + run: | + python - coverage.xml pytest-results.xml <<'PY' >> "$GITHUB_STEP_SUMMARY" + import glob, sys, xml.etree.ElementTree as ET + from pathlib import Path + + cov, junit = sys.argv[1], sys.argv[2:] + o = ["## Coverage Report", ""] + + t = f = s = 0 + seen = False + for pat in junit: + for path in sorted(glob.glob(pat)): + try: + r = ET.parse(path).getroot() + except ET.ParseError: + continue + seen = True + for su in (r.iter("testsuite") if r.tag == "testsuites" else [r]): + t += int(su.get("tests") or 0) + f += int(su.get("failures") or 0) + int(su.get("errors") or 0) + s += int(su.get("skipped") or 0) + if seen: + o += ["### Test Results", "", + "| Status | Passed | Failed | Skipped | Total |", + "|---|---:|---:|---:|---:|", + f"| {'✅ Passed' if not f else '❌ Failed'} | {t - f - s} | {f} | {s} | {t} |", ""] + + p = Path(cov) + if not p.exists(): + o += [f"> No coverage report at `{cov}`."] + else: + r = ET.parse(p).getroot() + c, v = int(r.get("lines-covered") or 0), int(r.get("lines-valid") or 0) + o += ["### Coverage Summary", "", "| Metric | Value |", "|---|---:|", + f"| Line coverage | **{(100.0 * c / v if v else 0):.1f}%** |", + f"| Lines covered | {c:,} / {v:,} |", + f"| Lines missing | {v - c:,} |", ""] + rows = [] + for cl in r.iter("class"): + ls = list(cl.iter("line")) + if ls: + h = sum(1 for x in ls if int(x.get("hits") or 0) > 0) + rows.append((100.0 * h / len(ls), + cl.get("filename") or cl.get("name") or "?", h, len(ls))) + rows.sort() + if rows: + o += ["
", + f"Per-file coverage ({len(rows)} files, least covered first)", + "", "| File | Coverage | Covered / Total |", "|---|---:|---:|"] + o += [f"| `{n}` | {q:.1f}% | {h} / {tt} |" for q, n, h, tt in rows] + o += ["", "
", ""] + print("\n".join(o)) + PY + # Consumed by the weekly coverage report, which reads the artifact from # the newest successful run on main. Name is per-matrix-version because # upload-artifact@v4 requires unique names within a run. diff --git a/.gitignore b/.gitignore index eb63accf..ed28f0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +pytest-results.xml *.cover *.py,cover .hypothesis/ diff --git a/Makefile b/Makefile index 2f72cb1a..7054ab0e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ test: uv run pytest test-coverage: - uv run pytest --cov=runpod --cov-report=term-missing --cov-report=xml + uv run pytest --cov=runpod --cov-report=term-missing --cov-report=xml --junitxml=pytest-results.xml # --- Build --- build: From bdb65de9fed961837b39f35a7f9e22f8bfac345d Mon Sep 17 00:00:00 2001 From: runpod-Henrik Date: Wed, 26 Aug 2026 08:28:25 -0700 Subject: [PATCH 2/6] fix: harden the coverage summary step against bad input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from #373. Three defects, all reachable because this step runs under `if: always()`: * the coverage-XML parse was unguarded while the junit parse beside it was. A report truncated by a timeout, OOM or crashed xdist worker made ET.parse raise, so the summary step exited non-zero and stacked a spurious failure on top of the real one. Verified: the old script exits 1 on a truncated report, the new one exits 0 and says so in the summary. * hits/lines attributes were parsed with bare int(), so a malformed value raised rather than degrading. * the status cell treated `0 failures` as passing even when no tests ran at all. A suite dying at import reports errors>0 with tests possibly 0, so the check is now `no failures AND at least one test`. Also switch the artifact upload to `if-no-files-found: warn`. With `error` a run that never produced coverage.xml — pytest erroring at collection, before pytest-cov writes anything — failed the upload step too, red-flagging the job and masking the root cause. This upload hangs off the PR-gating test job, so it should not be able to fail a PR on its own. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 60 +++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55b9b2d1..e9cbb38f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,51 +51,79 @@ jobs: import glob, sys, xml.etree.ElementTree as ET from pathlib import Path + MAX_ROWS = 300 cov, junit = sys.argv[1], sys.argv[2:] o = ["## Coverage Report", ""] + + def num(v, default=0): + """Attribute values are text, and a malformed one must degrade rather than + raise: this step runs under `if: always()`, so an exception here would + stack a spurious failure on top of whatever actually went wrong.""" + try: + return int(v) + except (TypeError, ValueError): + return default + + + def parse(path): + try: + return ET.parse(path).getroot(), None + except (ET.ParseError, OSError) as e: + return None, e + + t = f = s = 0 seen = False for pat in junit: for path in sorted(glob.glob(pat)): - try: - r = ET.parse(path).getroot() - except ET.ParseError: + r, _ = parse(path) + if r is None: continue seen = True for su in (r.iter("testsuite") if r.tag == "testsuites" else [r]): - t += int(su.get("tests") or 0) - f += int(su.get("failures") or 0) + int(su.get("errors") or 0) - s += int(su.get("skipped") or 0) + t += num(su.get("tests")) + f += num(su.get("failures")) + num(su.get("errors")) + s += num(su.get("skipped")) if seen: + # A suite dying at import reports errors>0 with tests possibly 0, so + # "no failures AND something actually ran" is what keeps a crashed run + # from rendering as passed. + ok = f == 0 and t > 0 o += ["### Test Results", "", "| Status | Passed | Failed | Skipped | Total |", "|---|---:|---:|---:|---:|", - f"| {'✅ Passed' if not f else '❌ Failed'} | {t - f - s} | {f} | {s} | {t} |", ""] + f"| {'✅ Passed' if ok else '❌ Failed'} | {t - f - s} | {f} | {s} | {t} |", ""] p = Path(cov) - if not p.exists(): - o += [f"> No coverage report at `{cov}`."] + root, err = (None, None) if not p.exists() else parse(p) + if root is None: + o += [f"> No coverage report at `{cov}`." if err is None + else f"> Coverage report at `{cov}` could not be parsed: {err}"] else: - r = ET.parse(p).getroot() - c, v = int(r.get("lines-covered") or 0), int(r.get("lines-valid") or 0) + c, v = num(root.get("lines-covered")), num(root.get("lines-valid")) o += ["### Coverage Summary", "", "| Metric | Value |", "|---|---:|", f"| Line coverage | **{(100.0 * c / v if v else 0):.1f}%** |", f"| Lines covered | {c:,} / {v:,} |", f"| Lines missing | {v - c:,} |", ""] rows = [] - for cl in r.iter("class"): + for cl in root.iter("class"): ls = list(cl.iter("line")) if ls: - h = sum(1 for x in ls if int(x.get("hits") or 0) > 0) + h = sum(1 for x in ls if num(x.get("hits")) > 0) rows.append((100.0 * h / len(ls), cl.get("filename") or cl.get("name") or "?", h, len(ls))) - rows.sort() + rows.sort(key=lambda r: (r[0], r[1])) if rows: + shown = rows[:MAX_ROWS] o += ["
", f"Per-file coverage ({len(rows)} files, least covered first)", "", "| File | Coverage | Covered / Total |", "|---|---:|---:|"] - o += [f"| `{n}` | {q:.1f}% | {h} / {tt} |" for q, n, h, tt in rows] + o += [f"| `{n}` | {q:.1f}% | {h} / {tt} |" for q, n, h, tt in shown] + # Capped so a growing repo cannot push the summary past GitHub's 1 MiB + # limit, which would truncate it silently. + if len(rows) > MAX_ROWS: + o += [f"| _…and {len(rows) - MAX_ROWS} more files_ | | |"] o += ["", "
", ""] print("\n".join(o)) PY @@ -109,7 +137,7 @@ jobs: with: name: coverage-${{ matrix.python-version }} path: coverage.xml - if-no-files-found: error + if-no-files-found: warn e2e: if: github.event_name != 'schedule' && github.repository == 'runpod/runpod-python' From 98416729c3194df3d70846f0e925e1b04f89a67d Mon Sep 17 00:00:00 2001 From: runpod-Henrik Date: Wed, 26 Aug 2026 12:56:53 -0700 Subject: [PATCH 3/6] feat: collect branch coverage Branch coverage was never collected, so the job summary could only ever show line coverage. Adds --cov-branch. Line coverage is unchanged at 94.3% (3299/3499), so the weekly coverage trend, which reads line coverage, is unaffected. The Cobertura report now carries branch data, which the summary renders as its own row: 88.1% (796/904). One thing to be aware of: --cov-fail-under in pytest.ini gates on coverage.py's total, which now blends lines and branches. That total moves 94.28% -> 93.00% against the 90% gate, so the margin narrows from 4.28 to 3.00 points. It passes, but the gate now measures something slightly different than it did. Happy to lower it to ~88 to preserve the original strictness, or to skip branch collection here, if the tighter margin is unwelcome. Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7054ab0e..88592bac 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ test: uv run pytest test-coverage: - uv run pytest --cov=runpod --cov-report=term-missing --cov-report=xml --junitxml=pytest-results.xml + uv run pytest --cov=runpod --cov-branch --cov-report=term-missing --cov-report=xml --junitxml=pytest-results.xml # --- Build --- build: From 66135e86c0e98e14a1d37c7fc6e5e311634db139 Mon Sep 17 00:00:00 2001 From: runpod-Henrik Date: Wed, 26 Aug 2026 13:49:41 -0700 Subject: [PATCH 4/6] refactor: use the shared coverage-summary action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline summary script with runpod/internal-workflows/.github/actions/coverage-summary, which now owns both the summary rendering and the artifact upload. The same ~90-line script had been copy-pasted into six repos, and review found the same bugs in every copy — four rounds of a single class where "passed" was the default state and each new way of losing a report had to be patched out of it separately. The shared version computes it from positive evidence instead, and has 59 unit tests plus a smoke job behind it. Pinned by commit rather than tag, so a change to the action cannot reach this repo until someone bumps the SHA. Behaviour was proven on runpod/github-image-builder first: green run, artifact uploaded, and the weekly collector parsed it back at the expected number before the remaining repos followed. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 97 +++------------------------------------- 1 file changed, 7 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9cbb38f..1ab29014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,99 +44,16 @@ jobs: env: UV_PYTHON: ${{ matrix.python-version }} + # Shared with the other repos' coverage jobs. Pinned by commit rather + # than tag: a change to the action cannot reach us until we bump it. - name: Coverage summary if: always() - run: | - python - coverage.xml pytest-results.xml <<'PY' >> "$GITHUB_STEP_SUMMARY" - import glob, sys, xml.etree.ElementTree as ET - from pathlib import Path - - MAX_ROWS = 300 - cov, junit = sys.argv[1], sys.argv[2:] - o = ["## Coverage Report", ""] - - - def num(v, default=0): - """Attribute values are text, and a malformed one must degrade rather than - raise: this step runs under `if: always()`, so an exception here would - stack a spurious failure on top of whatever actually went wrong.""" - try: - return int(v) - except (TypeError, ValueError): - return default - - - def parse(path): - try: - return ET.parse(path).getroot(), None - except (ET.ParseError, OSError) as e: - return None, e - - - t = f = s = 0 - seen = False - for pat in junit: - for path in sorted(glob.glob(pat)): - r, _ = parse(path) - if r is None: - continue - seen = True - for su in (r.iter("testsuite") if r.tag == "testsuites" else [r]): - t += num(su.get("tests")) - f += num(su.get("failures")) + num(su.get("errors")) - s += num(su.get("skipped")) - if seen: - # A suite dying at import reports errors>0 with tests possibly 0, so - # "no failures AND something actually ran" is what keeps a crashed run - # from rendering as passed. - ok = f == 0 and t > 0 - o += ["### Test Results", "", - "| Status | Passed | Failed | Skipped | Total |", - "|---|---:|---:|---:|---:|", - f"| {'✅ Passed' if ok else '❌ Failed'} | {t - f - s} | {f} | {s} | {t} |", ""] - - p = Path(cov) - root, err = (None, None) if not p.exists() else parse(p) - if root is None: - o += [f"> No coverage report at `{cov}`." if err is None - else f"> Coverage report at `{cov}` could not be parsed: {err}"] - else: - c, v = num(root.get("lines-covered")), num(root.get("lines-valid")) - o += ["### Coverage Summary", "", "| Metric | Value |", "|---|---:|", - f"| Line coverage | **{(100.0 * c / v if v else 0):.1f}%** |", - f"| Lines covered | {c:,} / {v:,} |", - f"| Lines missing | {v - c:,} |", ""] - rows = [] - for cl in root.iter("class"): - ls = list(cl.iter("line")) - if ls: - h = sum(1 for x in ls if num(x.get("hits")) > 0) - rows.append((100.0 * h / len(ls), - cl.get("filename") or cl.get("name") or "?", h, len(ls))) - rows.sort(key=lambda r: (r[0], r[1])) - if rows: - shown = rows[:MAX_ROWS] - o += ["
", - f"Per-file coverage ({len(rows)} files, least covered first)", - "", "| File | Coverage | Covered / Total |", "|---|---:|---:|"] - o += [f"| `{n}` | {q:.1f}% | {h} / {tt} |" for q, n, h, tt in shown] - # Capped so a growing repo cannot push the summary past GitHub's 1 MiB - # limit, which would truncate it silently. - if len(rows) > MAX_ROWS: - o += [f"| _…and {len(rows) - MAX_ROWS} more files_ | | |"] - o += ["", "
", ""] - print("\n".join(o)) - PY - - # Consumed by the weekly coverage report, which reads the artifact from - # the newest successful run on main. Name is per-matrix-version because - # upload-artifact@v4 requires unique names within a run. - - name: Upload coverage report - uses: actions/upload-artifact@v4 - if: always() + uses: runpod/internal-workflows/.github/actions/coverage-summary@a9dcd07aabffbe72fbb6a12d7baeb9fa8150a0bf with: - name: coverage-${{ matrix.python-version }} - path: coverage.xml + format: cobertura + coverage-file: coverage.xml + results: pytest-results.xml + artifact-name: coverage-${{ matrix.python-version }} if-no-files-found: warn e2e: From b0ed55463ddfe4763084ea572579e90da17a36b7 Mon Sep 17 00:00:00 2001 From: runpod-Henrik Date: Wed, 26 Aug 2026 13:53:42 -0700 Subject: [PATCH 5/6] revert: keep the inline coverage summary here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public repository cannot consume an action from a private one, and runpod/internal-workflows is private. Every matrix leg failed at "Set up job" with: Unable to resolve action `runpod/internal-workflows`, not found This reverts the migration to the shared action for this repo. The inline script is restored, including all the hardening from review. The shared action still applies to the private repos — github-image-builder is migrated and green, main-ui and RunPod follow. Sharing it here needs internal-workflows to be public, or the action published somewhere public. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 97 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ab29014..e9cbb38f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,16 +44,99 @@ jobs: env: UV_PYTHON: ${{ matrix.python-version }} - # Shared with the other repos' coverage jobs. Pinned by commit rather - # than tag: a change to the action cannot reach us until we bump it. - name: Coverage summary if: always() - uses: runpod/internal-workflows/.github/actions/coverage-summary@a9dcd07aabffbe72fbb6a12d7baeb9fa8150a0bf + run: | + python - coverage.xml pytest-results.xml <<'PY' >> "$GITHUB_STEP_SUMMARY" + import glob, sys, xml.etree.ElementTree as ET + from pathlib import Path + + MAX_ROWS = 300 + cov, junit = sys.argv[1], sys.argv[2:] + o = ["## Coverage Report", ""] + + + def num(v, default=0): + """Attribute values are text, and a malformed one must degrade rather than + raise: this step runs under `if: always()`, so an exception here would + stack a spurious failure on top of whatever actually went wrong.""" + try: + return int(v) + except (TypeError, ValueError): + return default + + + def parse(path): + try: + return ET.parse(path).getroot(), None + except (ET.ParseError, OSError) as e: + return None, e + + + t = f = s = 0 + seen = False + for pat in junit: + for path in sorted(glob.glob(pat)): + r, _ = parse(path) + if r is None: + continue + seen = True + for su in (r.iter("testsuite") if r.tag == "testsuites" else [r]): + t += num(su.get("tests")) + f += num(su.get("failures")) + num(su.get("errors")) + s += num(su.get("skipped")) + if seen: + # A suite dying at import reports errors>0 with tests possibly 0, so + # "no failures AND something actually ran" is what keeps a crashed run + # from rendering as passed. + ok = f == 0 and t > 0 + o += ["### Test Results", "", + "| Status | Passed | Failed | Skipped | Total |", + "|---|---:|---:|---:|---:|", + f"| {'✅ Passed' if ok else '❌ Failed'} | {t - f - s} | {f} | {s} | {t} |", ""] + + p = Path(cov) + root, err = (None, None) if not p.exists() else parse(p) + if root is None: + o += [f"> No coverage report at `{cov}`." if err is None + else f"> Coverage report at `{cov}` could not be parsed: {err}"] + else: + c, v = num(root.get("lines-covered")), num(root.get("lines-valid")) + o += ["### Coverage Summary", "", "| Metric | Value |", "|---|---:|", + f"| Line coverage | **{(100.0 * c / v if v else 0):.1f}%** |", + f"| Lines covered | {c:,} / {v:,} |", + f"| Lines missing | {v - c:,} |", ""] + rows = [] + for cl in root.iter("class"): + ls = list(cl.iter("line")) + if ls: + h = sum(1 for x in ls if num(x.get("hits")) > 0) + rows.append((100.0 * h / len(ls), + cl.get("filename") or cl.get("name") or "?", h, len(ls))) + rows.sort(key=lambda r: (r[0], r[1])) + if rows: + shown = rows[:MAX_ROWS] + o += ["
", + f"Per-file coverage ({len(rows)} files, least covered first)", + "", "| File | Coverage | Covered / Total |", "|---|---:|---:|"] + o += [f"| `{n}` | {q:.1f}% | {h} / {tt} |" for q, n, h, tt in shown] + # Capped so a growing repo cannot push the summary past GitHub's 1 MiB + # limit, which would truncate it silently. + if len(rows) > MAX_ROWS: + o += [f"| _…and {len(rows) - MAX_ROWS} more files_ | | |"] + o += ["", "
", ""] + print("\n".join(o)) + PY + + # Consumed by the weekly coverage report, which reads the artifact from + # the newest successful run on main. Name is per-matrix-version because + # upload-artifact@v4 requires unique names within a run. + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() with: - format: cobertura - coverage-file: coverage.xml - results: pytest-results.xml - artifact-name: coverage-${{ matrix.python-version }} + name: coverage-${{ matrix.python-version }} + path: coverage.xml if-no-files-found: warn e2e: From b020f03abf62f46ba74d93b07badedeaef0dff47 Mon Sep 17 00:00:00 2001 From: runpod-Henrik Date: Thu, 27 Aug 2026 10:34:40 -0700 Subject: [PATCH 6/6] refactor: use the shared coverage-summary action from its public repo Replaces the ~90-line inline heredoc summary with the shared action, now that there is a host this repo can actually consume. The first attempt at this pointed at a private repo and failed at `Set up job` on every matrix leg with "Unable to resolve action, not found", so no tests ran at all and it was reverted. A public repository cannot resolve an action from a private one, and this repo is public. The action now lives in runpod/coverage-summary-action, and the same commit is already green in three private consumers plus this repo's sibling. The action owns the coverage artifact upload, so the separate "Upload coverage report" step is gone -- that is the same artifact, not a second one. Note for whoever revisits the gate: --cov-branch narrowed the margin against --cov-fail-under=90 in pytest.ini from 4.28 to 3.00 points. Unchanged here, but it is thinner than it looks. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 104 ++++++--------------------------------- 1 file changed, 14 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9cbb38f..fea4e0fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,99 +44,23 @@ jobs: env: UV_PYTHON: ${{ matrix.python-version }} + # Shared with the other repos' coverage jobs. It lives in its own public + # repo because a public repository -- this one -- cannot resolve an action + # from a private one. An earlier attempt to consume it from the private + # internal-workflows repo failed at `Set up job` on every matrix leg with + # "Unable to resolve action, not found", so no tests ran at all. + # + # Pinned by commit rather than tag: a tag can be repointed at new code, + # and pinning means a change to the action cannot reach us until we bump + # it deliberately. - name: Coverage summary if: always() - run: | - python - coverage.xml pytest-results.xml <<'PY' >> "$GITHUB_STEP_SUMMARY" - import glob, sys, xml.etree.ElementTree as ET - from pathlib import Path - - MAX_ROWS = 300 - cov, junit = sys.argv[1], sys.argv[2:] - o = ["## Coverage Report", ""] - - - def num(v, default=0): - """Attribute values are text, and a malformed one must degrade rather than - raise: this step runs under `if: always()`, so an exception here would - stack a spurious failure on top of whatever actually went wrong.""" - try: - return int(v) - except (TypeError, ValueError): - return default - - - def parse(path): - try: - return ET.parse(path).getroot(), None - except (ET.ParseError, OSError) as e: - return None, e - - - t = f = s = 0 - seen = False - for pat in junit: - for path in sorted(glob.glob(pat)): - r, _ = parse(path) - if r is None: - continue - seen = True - for su in (r.iter("testsuite") if r.tag == "testsuites" else [r]): - t += num(su.get("tests")) - f += num(su.get("failures")) + num(su.get("errors")) - s += num(su.get("skipped")) - if seen: - # A suite dying at import reports errors>0 with tests possibly 0, so - # "no failures AND something actually ran" is what keeps a crashed run - # from rendering as passed. - ok = f == 0 and t > 0 - o += ["### Test Results", "", - "| Status | Passed | Failed | Skipped | Total |", - "|---|---:|---:|---:|---:|", - f"| {'✅ Passed' if ok else '❌ Failed'} | {t - f - s} | {f} | {s} | {t} |", ""] - - p = Path(cov) - root, err = (None, None) if not p.exists() else parse(p) - if root is None: - o += [f"> No coverage report at `{cov}`." if err is None - else f"> Coverage report at `{cov}` could not be parsed: {err}"] - else: - c, v = num(root.get("lines-covered")), num(root.get("lines-valid")) - o += ["### Coverage Summary", "", "| Metric | Value |", "|---|---:|", - f"| Line coverage | **{(100.0 * c / v if v else 0):.1f}%** |", - f"| Lines covered | {c:,} / {v:,} |", - f"| Lines missing | {v - c:,} |", ""] - rows = [] - for cl in root.iter("class"): - ls = list(cl.iter("line")) - if ls: - h = sum(1 for x in ls if num(x.get("hits")) > 0) - rows.append((100.0 * h / len(ls), - cl.get("filename") or cl.get("name") or "?", h, len(ls))) - rows.sort(key=lambda r: (r[0], r[1])) - if rows: - shown = rows[:MAX_ROWS] - o += ["
", - f"Per-file coverage ({len(rows)} files, least covered first)", - "", "| File | Coverage | Covered / Total |", "|---|---:|---:|"] - o += [f"| `{n}` | {q:.1f}% | {h} / {tt} |" for q, n, h, tt in shown] - # Capped so a growing repo cannot push the summary past GitHub's 1 MiB - # limit, which would truncate it silently. - if len(rows) > MAX_ROWS: - o += [f"| _…and {len(rows) - MAX_ROWS} more files_ | | |"] - o += ["", "
", ""] - print("\n".join(o)) - PY - - # Consumed by the weekly coverage report, which reads the artifact from - # the newest successful run on main. Name is per-matrix-version because - # upload-artifact@v4 requires unique names within a run. - - name: Upload coverage report - uses: actions/upload-artifact@v4 - if: always() + uses: runpod/coverage-summary-action@65b35a32ecfd9c2f0dc2352394eca1decfba4915 with: - name: coverage-${{ matrix.python-version }} - path: coverage.xml + format: cobertura + coverage-file: coverage.xml + results: pytest-results.xml + artifact-name: coverage-${{ matrix.python-version }} if-no-files-found: warn e2e: