Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion .github/workflows/ci-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,31 @@ jobs:
# --dist loadgroup is required: it is the only xdist scheduler
# that honors pytest.mark.xdist_group, which we use to keep
# HOME-mutating tests serialized on a single worker.
PYTEST_EXTRA_ARGS: "--splits 4 --group ${{ matrix.shard }} -n 2 --dist loadgroup --maxfail 10"
PYTEST_EXTRA_ARGS: "--splits 4 --group ${{ matrix.shard }} -n 2 --dist loadgroup --maxfail 10 --cov"
run: |
chmod +x scripts/test-integration.sh
uv run ./scripts/test-integration.sh
# Per-shard timeout is ~1/4 of the original 30 min budget plus
# headroom for the slowest shard (HOME-group tests).
timeout-minutes: 15

- name: Rename coverage data for shard
if: always()
run: |
if [ -f .coverage ]; then
mv .coverage .coverage.shard-${{ matrix.shard }}
fi

- name: Upload coverage data
if: always()
uses: actions/upload-artifact@v4
with:
name: integration-coverage-shard-${{ matrix.shard }}
path: .coverage.shard-${{ matrix.shard }}
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
include-hidden-files: true
retention-days: 7
if-no-files-found: ignore

# Fan-in job that preserves the gate-required check name
# "Integration Tests (Linux)". Branch protection / merge-gate.yml
# require this exact name; shard jobs above are not required directly.
Expand All @@ -219,6 +236,40 @@ jobs:
if: always()
runs-on: ubuntu-24.04
steps:
# Coverage summary runs first (with if: always()) so we get
# observability even when some shards fail. The shard-result
# gate runs last so it can still fail the job.
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Install coverage
run: pip install "coverage[toml]"
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

- name: Download shard coverage
if: always()
uses: actions/download-artifact@v4
with:
pattern: integration-coverage-shard-*
path: coverage-shards/
merge-multiple: true
continue-on-error: true

- name: Combine and summarise coverage
if: always()
run: |
if find coverage-shards -type f -name '.coverage*' 2>/dev/null | grep -q .; then
coverage combine coverage-shards/
coverage json -o coverage.json
python3 scripts/coverage-summary.py coverage.json --title "Integration Test Coverage"
else
echo "No coverage shard files found; skipping integration coverage summary."
fi
continue-on-error: true

- name: Aggregate shard results
run: |
if [[ "${{ needs.integration-tests-shard.result }}" != "success" ]]; then
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,15 @@ jobs:
- name: Install dependencies
run: uv sync --extra dev --extra build

- name: Run tests
run: uv run pytest tests/unit tests/test_console.py -n auto --dist worksteal
- name: Run tests with coverage
run: uv run pytest tests/unit tests/test_console.py -n auto --dist worksteal --cov --cov-report=term-missing:skip-covered --cov-report=json:coverage.json
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

- name: Coverage summary
if: always()
run: |
if [ -f coverage.json ]; then
python3 scripts/coverage-summary.py coverage.json --title "Unit Test Coverage"
fi

- name: Install UPX
run: |
Expand Down
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,23 @@ python_version = "3.12"
warn_return_any = true
warn_unused_configs = true

[tool.coverage.run]
source = ["src/apm_cli"]
branch = true
parallel = true

[tool.coverage.report]
show_missing = true
skip_empty = true
# Exclude lines that are not meant to be covered
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@overload",
]

[tool.pytest.ini_options]
addopts = "-m 'not benchmark and not live'"
markers = [
Expand Down
127 changes: 127 additions & 0 deletions scripts/coverage-summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Render a coverage.json file as a Markdown summary.

Usage:
python3 scripts/coverage-summary.py coverage.json [--title "My Title"]

Writes Markdown to stdout and, if GITHUB_STEP_SUMMARY is set, appends to
that file so the summary appears in the GitHub Actions job summary panel.

Exit codes:
0 Always -- missing coverage.json is treated as a soft warning, not
an error, so CI steps that set ``if: always()`` never fail here.
"""

import argparse
import json
import os
import pathlib
import sys


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render a coverage.json file as a Markdown summary."
)
parser.add_argument(
"coverage_json",
metavar="COVERAGE_JSON",
help="Path to coverage.json produced by `coverage json`.",
)
parser.add_argument(
"--title",
default="Code Coverage Report",
help="Heading text for the summary (default: 'Code Coverage Report').",
)
return parser.parse_args()


def _build_markdown(data: dict, title: str) -> str:
totals = data.get("totals", {})
pct = totals.get("percent_covered_display", "N/A")
stmts = totals.get("num_statements", 0)
miss = totals.get("missing_lines", 0)
covered = stmts - miss

lines: list[str] = []
lines.append(f"## {title}")
lines.append("")
lines.append(f"**Overall: {pct}%** ({covered:,}/{stmts:,} statements)")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Statements | {stmts:,} |")
lines.append(f"| Covered | {covered:,} |")
lines.append(f"| Missed | {miss:,} |")
lines.append(f"| Coverage | {pct}% |")
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
lines.append("")

files = data.get("files", {})
ranked = sorted(
files.items(),
key=lambda kv: kv[1].get("summary", {}).get("percent_covered", 100.0),
)
# Always show bottom-10 files so the section appears even when
# overall coverage is high (acceptance criteria: "collapsible
# lowest-coverage files" in every summary).
bottom = ranked[:10]

if bottom:
lines.append("<details>")
lines.append("<summary>Lowest-coverage files</summary>")
lines.append("")
lines.append("| File | Stmts | Miss | Cover |")
lines.append("|------|-------|------|-------|")
for fpath, fdata in bottom:
s = fdata.get("summary", {})
# Strip common prefix to keep the table narrow.
short = fpath.replace("src/apm_cli/", "")
fp = s.get("percent_covered_display", "?")
lines.append(
f"| `{short}` | {s.get('num_statements', 0)}"
f" | {s.get('missing_lines', 0)} | {fp}% |"
)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
lines.append("")
lines.append("</details>")
lines.append("")

return "\n".join(lines) + "\n"


def main() -> None:
args = _parse_args()
coverage_path = pathlib.Path(args.coverage_json)

if not coverage_path.exists():
print(
f"[!] coverage-summary: {coverage_path} not found -- skipping summary.",
file=sys.stderr,
)
sys.exit(0)

try:
data = json.loads(coverage_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
print(
f"[x] coverage-summary: failed to read {coverage_path}: {exc}",
file=sys.stderr,
)
sys.exit(0)

md = _build_markdown(data, args.title)
print(md, end="")

summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "")
if summary_path:
try:
with open(summary_path, "a", encoding="utf-8") as fh:
fh.write(md)
except OSError as exc:
print(
f"[!] coverage-summary: could not write to GITHUB_STEP_SUMMARY: {exc}",
file=sys.stderr,
)


if __name__ == "__main__":
main()
Loading