Files
Stirling-PDF/scripts/coverage-matrix.py
T
05eb74022a chore(ci): migrate Python tooling to uv and standardize workflow execution (#7386)
# Description of Changes

This PR modernizes the project's Python tooling across GitHub Actions by
migrating CI workflows from pip-based dependency management to `uv` and
aligning Python execution with the engine project's managed environment.

### What was changed

- Replaced `actions/setup-python` and ad-hoc `pip install` steps with
`astral-sh/setup-uv` across CI workflows.
- Configured shared `uv` dependency caching using
`engine/pyproject.toml` and `engine/uv.lock`.
- Updated Python script execution to use `uv run --project engine
--locked` for a consistent runtime environment.
- Replaced package installation steps with `uv sync` for the required
dependency groups (e.g. `tools` and `cucumber`).
- Added Docker image build validation for both production and
development AI engine images.
- Updated workflow cache configuration and Docker build context where
required.
- Removed obsolete Python requirements files that are no longer needed
after the migration.
- Applied minor Python code modernizations, including import cleanup,
modern built-in generic type annotations (`list[...]`, `tuple[...]`,
`float | None`), and small style improvements.
- Removed unnecessary Python formatter/linter extensions from the
development container configuration.

### Why the change was made

- Standardize Python dependency management across the repository.
- Reduce duplicated dependency installation logic in CI.
- Improve workflow performance through shared dependency caching.
- Ensure all Python utilities execute against the same locked dependency
set managed by the engine project.
- Simplify long-term maintenance by eliminating legacy requirements
files and pip-specific workflow steps.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Signed-off-by: Carsten Drewes <c.drewes@stud.uni-hannover.de>
Co-authored-by: albanobattistella <34811668+albanobattistella@users.noreply.github.com>
Co-authored-by: kastenherri <116314318+kastenherri@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-08-11 08:09:21 +00:00

318 lines
11 KiB
Python

#!/usr/bin/env python3
"""Render a single coverage matrix combining backend + frontend, per area.
Rows:
- Frontend (e2e only) - Playwright fixture V8 capture
- Frontend (all) - Vitest unit tests + Playwright V8
- Backend (e2e only) - Playwright live JaCoCo + cucumber JaCoCo
- Backend (all) - JUnit + e2e:live + cucumber JaCoCo
- Both (e2e only) - frontend (e2e) + backend (e2e), summed
- Both (all) - frontend (all) + backend (all), summed
Columns:
- core, proprietary, saas, desktop - bucketed per area
- ALL - sum across the row
Bucketing rules:
Backend (JaCoCo package -> area):
stirling/software/SPDF/** -> core (the main backend module)
stirling/software/common/** -> core (shared infra, attributed to core)
org/apache/pdfbox/** -> core (vendored helpers in common/core)
stirling/software/proprietary/** -> proprietary (unless saas-flavoured)
stirling/software/saas/** -> saas
*desktop* -> desktop (no real backend match today)
Frontend (source path -> area):
frontend/editor/src/core/** -> core
frontend/editor/src/proprietary/** -> proprietary
frontend/editor/src/saas/** -> saas
frontend/editor/src/desktop/** -> desktop
Cells render as `pct% (covered/total)` where the metric is:
- Backend: JaCoCo METHOD counter (most directly comparable to JS funcs)
- Frontend: function counts from vitest per-file summary
Playwright (live) frontend V8 dumps only know about bundled JS URLs, not
source paths, so they only feed the ALL column for the Frontend rows.
Per-area Playwright contributions show "n/a" with a footnote.
Missing inputs are tolerated: any source that isn't passed renders as `-`
and the row aggregates from what is available.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from defusedxml.ElementTree import ParseError as _XMLParseError
# defusedxml hardens the parser against XXE / billion-laughs / entity-
# expansion attacks. JaCoCo XML on a CI runner is trusted input today,
# but using the hardened parser is a one-line change and silences
# scanners that pattern-match on `xml.etree.ElementTree.parse`.
from defusedxml.ElementTree import parse as _xml_parse
AREAS = ("core", "proprietary", "saas", "desktop")
@dataclass
class Bucket:
"""Covered + total counters that can be combined across sources."""
covered: int = 0
total: int = 0
@property
def pct(self) -> float:
return 100.0 * self.covered / self.total if self.total else 0.0
def add(self, other: Bucket) -> None:
self.covered += other.covered
self.total += other.total
@dataclass
class RowBuckets:
"""Per-area buckets for one row of the matrix."""
by_area: dict[str, Bucket] = field(default_factory=lambda: {a: Bucket() for a in AREAS})
# Some inputs (Playwright V8) don't have source-path info, so they
# only contribute to ALL without an area attribution. Track those
# separately so per-area cells stay honest.
unattributed: Bucket = field(default_factory=Bucket)
@property
def all(self) -> Bucket:
agg = Bucket()
for b in self.by_area.values():
agg.add(b)
agg.add(self.unattributed)
return agg
def merge(self, other: RowBuckets) -> None:
for area in AREAS:
self.by_area[area].add(other.by_area[area])
self.unattributed.add(other.unattributed)
# --------------------------------------------------------------------- jacoco
def _classify_backend(package_name: str) -> str | None:
"""Map a JaCoCo package name to an area, or None to skip."""
if not package_name:
return None
p = package_name.replace("/", ".")
if "desktop" in p:
return "desktop"
if p.startswith("stirling.software.saas"):
return "saas"
# Proprietary saas-flavoured sub-package: rare, but kept for forward
# compatibility if a future build moves saas under proprietary.
if p.startswith("stirling.software.proprietary") and ".saas" in p:
return "saas"
if p.startswith("stirling.software.proprietary"):
return "proprietary"
if (
p.startswith("stirling.software.SPDF")
or p.startswith("stirling.software.common")
or p.startswith("org.apache.pdfbox")
):
return "core"
return None
def parse_jacoco_methods(path: Path) -> RowBuckets:
row = RowBuckets()
if not path.exists():
return row
try:
root = _xml_parse(path).getroot()
except _XMLParseError as exc:
print(f"::warning::Failed to parse {path}: {exc}", file=sys.stderr)
return row
for pkg in root.findall("package"):
area = _classify_backend(pkg.get("name", ""))
if area is None:
continue
for counter in pkg.findall("counter"):
if counter.get("type") != "METHOD":
continue
covered = int(counter.get("covered") or 0)
missed = int(counter.get("missed") or 0)
row.by_area[area].add(Bucket(covered=covered, total=covered + missed))
return row
# --------------------------------------------------------------- vitest (frontend)
def _classify_frontend(file_path: str) -> str | None:
"""Map a vitest per-file path (anything containing src/<area>/) to an area."""
if not file_path:
return None
norm = file_path.replace("\\", "/")
for area in AREAS:
if f"/src/{area}/" in norm:
return area
return None
def parse_vitest_per_file(path: Path) -> RowBuckets:
row = RowBuckets()
if not path.exists():
return row
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
print(f"::warning::Failed to parse vitest summary {path}: {exc}", file=sys.stderr)
return row
for file_path, metrics in data.items():
if file_path == "total":
continue
area = _classify_frontend(file_path)
if area is None:
continue
fn = (metrics or {}).get("functions") or {}
covered = int(fn.get("covered") or 0)
total = int(fn.get("total") or 0)
row.by_area[area].add(Bucket(covered=covered, total=total))
return row
# -------------------------------------------------- playwright frontend (V8)
def parse_playwright_frontend_total(path: Path) -> RowBuckets:
"""Playwright's V8 dump aggregator only knows the grand total.
Without source maps we can't bucket per area, so the whole number
goes into `unattributed`. The matrix renderer surfaces this in the
ALL column and shows "n/a" in per-area cells with a footnote.
"""
row = RowBuckets()
if not path.exists():
return row
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
print(
f"::warning::Failed to parse Playwright summary {path}: {exc}",
file=sys.stderr,
)
return row
fn = (data.get("total") or {}).get("functions") or {}
covered = int(fn.get("covered") or 0)
total = int(fn.get("total") or 0)
row.unattributed.add(Bucket(covered=covered, total=total))
return row
# ---------------------------------------------------------------- rendering
def _cell(bucket: Bucket) -> str:
if bucket.total == 0:
return "-"
return f"{bucket.pct:.1f}% ({bucket.covered}/{bucket.total})"
def render(rows: dict[str, RowBuckets], title: str) -> str:
cols = list(AREAS) + ["ALL"]
header = "| Row | " + " | ".join(c for c in cols) + " |"
sep = "|---" * (len(cols) + 1) + "|"
lines = [f"## {title}", "", header, sep]
for row_label, row in rows.items():
is_backend_row = row_label.lower().startswith("backend")
cells = [row_label]
for area in AREAS:
b = row.by_area[area]
unattr = row.unattributed
if area == "desktop" and is_backend_row:
cells.append("n/a")
continue
if b.total == 0 and unattr.total > 0:
cells.append("n/a")
else:
cells.append(_cell(b))
cells.append(_cell(row.all))
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)
# ------------------------------------------------------------------- main
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--jacoco-all", type=Path, help="aggregate jacoco-all XML")
parser.add_argument("--jacoco-e2e", type=Path, help="aggregate jacoco-e2e XML")
parser.add_argument("--vitest", type=Path, help="vitest coverage-summary.json")
parser.add_argument(
"--playwright-frontend",
type=Path,
help="Playwright frontend coverage-summary.json (from playwright-coverage-summary.py)",
)
parser.add_argument("--title", default="Coverage matrix")
parser.add_argument("--out", type=Path)
parser.add_argument("--github-step-summary", action="store_true")
parser.add_argument("--stdout", action="store_true")
args = parser.parse_args(argv)
# Build each row from its source(s).
fe_e2e = parse_playwright_frontend_total(args.playwright_frontend) if args.playwright_frontend else RowBuckets()
fe_unit = parse_vitest_per_file(args.vitest) if args.vitest else RowBuckets()
fe_all = RowBuckets()
fe_all.merge(fe_unit)
fe_all.merge(fe_e2e)
be_e2e = parse_jacoco_methods(args.jacoco_e2e) if args.jacoco_e2e else RowBuckets()
be_all = parse_jacoco_methods(args.jacoco_all) if args.jacoco_all else RowBuckets()
both_e2e = RowBuckets()
both_e2e.merge(fe_e2e)
both_e2e.merge(be_e2e)
both_all = RowBuckets()
both_all.merge(fe_all)
both_all.merge(be_all)
rows = {
"Frontend (e2e only)": fe_e2e,
"Frontend (all)": fe_all,
"Backend (e2e only)": be_e2e,
"Backend (all)": be_all,
"Both (e2e only)": both_e2e,
"Both (all)": both_all,
}
body = render(rows, args.title) + "\n"
# Always write to stdout when no sink is selected so the script
# is useful from the command line.
sinks: list[Path] = []
if args.out:
sinks.append(args.out)
if args.github_step_summary:
import os
gh = os.environ.get("GITHUB_STEP_SUMMARY")
if gh:
sinks.append(Path(gh))
for sink in sinks:
sink.parent.mkdir(parents=True, exist_ok=True)
with sink.open("a", encoding="utf-8") as handle:
handle.write(body)
if args.stdout or not sinks:
print(body)
return 0
if __name__ == "__main__":
sys.exit(main())