Coverage for scripts / update_badge.py: 100%
295 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-11 04:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-11 04:34 +0000
1#!/usr/bin/env python3
2"""
3Detect a coverage output file, extract the line coverage percentage,
4and update the shields.io badge URL in a README file.
6Supported formats (auto-detected in priority order):
7 LCOV **/lcov.info
8 Cobertura **/cobertura.xml, **/coverage.xml
9 Coveralls **/coveralls.json
10 Istanbul **/coverage-summary.json
11"""
13from __future__ import annotations
15import json
16import logging
17import os
18import re
19import sys
20import tempfile
21import xml.etree.ElementTree as ET
22from collections.abc import Callable, Iterator
23from pathlib import Path
24from urllib.parse import quote, unquote
26# ---------------------------------------------------------------------------
27# Logging
28# ---------------------------------------------------------------------------
30logger = logging.getLogger(__name__)
33class _GitHubActionsFormatter(logging.Formatter):
34 """Format log records as GitHub Actions workflow commands.
36 ERROR and WARNING records are prefixed with the corresponding annotation
37 command so GitHub renders them in the job summary and step log.
38 """
40 def format(self, record: logging.LogRecord) -> str:
41 msg = record.getMessage()
42 if record.levelno >= logging.ERROR:
43 return f"::error::{msg}"
44 if record.levelno >= logging.WARNING:
45 return f"::warning::{msg}"
46 return msg
49def _configure_logging() -> None:
50 """Attach a stdout handler with the GitHub Actions formatter to the module logger.
52 Called only when the script runs directly so that test imports do not
53 install handlers; tests capture records via pytest's caplog fixture instead.
54 """
55 handler = logging.StreamHandler(sys.stdout)
56 handler.setFormatter(_GitHubActionsFormatter())
57 logger.addHandler(handler)
58 logger.setLevel(logging.DEBUG)
61# ---------------------------------------------------------------------------
62# Parsers
63# ---------------------------------------------------------------------------
66def _parse_lcov_int(field: str, raw: str, path: str) -> int:
67 """Parse an integer from an LCOV field value, raising ValueError on failure."""
68 try:
69 return int(raw)
70 except ValueError as exc:
71 raise ValueError(
72 f"Malformed {field}: record in {path!r}: {field}:{raw!r}"
73 ) from exc
76def parse_lcov(path: str) -> float:
77 """Sum LF (lines found) and LH (lines hit) records across all source files.
79 Args:
80 path: Path to the LCOV file to parse.
82 Returns:
83 Line coverage as a percentage in the range [0, 100].
85 Raises:
86 ValueError: If LF records are missing, malformed, or LH exceeds LF.
87 OSError: If the file cannot be opened.
88 """
89 lf = lh = 0
90 with open(path, encoding="utf-8") as f:
91 for line in f:
92 line = line.strip()
93 if line.startswith("LF:"):
94 lf += _parse_lcov_int("LF", line[3:], path)
95 elif line.startswith("LH:"):
96 lh += _parse_lcov_int("LH", line[3:], path)
97 if not lf:
98 raise ValueError(f"No LF: records found in LCOV file: {path!r}")
99 if lh > lf:
100 raise ValueError(f"Invalid LCOV data in {path!r}: LH ({lh}) exceeds LF ({lf})")
101 return lh / lf * 100
104_MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB
107def _check_xml_safety(path: str) -> None:
108 """Raise ValueError if the XML file contains a DOCTYPE declaration.
110 Coverage files never require DOCTYPE declarations; their presence indicates
111 either a non-coverage file or a crafted input that could trigger XML entity
112 expansion attacks (billion laughs).
114 Args:
115 path: Path to the XML file to inspect.
117 Raises:
118 ValueError: If a DOCTYPE declaration is detected in the first 4096 bytes.
119 OSError: If the file cannot be opened.
120 """
121 with open(path, encoding="utf-8", errors="replace") as f:
122 header = f.read(4096)
123 if re.search(r"<!DOCTYPE", header, re.IGNORECASE):
124 raise ValueError(
125 f"DOCTYPE declarations are not permitted in coverage files: {path!r}"
126 )
129def parse_cobertura(path: str) -> float:
130 """Read the line-rate attribute from the root coverage element (0–1 scale).
132 Args:
133 path: Path to the Cobertura XML file to parse.
135 Returns:
136 Line coverage as a percentage in the range [0, 100].
138 Raises:
139 ValueError: If the file exceeds 50 MB, contains a DOCTYPE declaration,
140 is missing a ``<coverage>`` element, is missing the ``line-rate``
141 attribute, or has a non-numeric ``line-rate`` value.
142 xml.etree.ElementTree.ParseError: If the file is not valid XML.
143 OSError: If the file cannot be opened.
144 """
145 size = os.path.getsize(path)
146 if size > _MAX_FILE_BYTES:
147 raise ValueError(
148 f"Coverage file is too large to parse safely: {path!r} ({size} bytes)"
149 )
150 _check_xml_safety(path)
151 tree = ET.parse(path)
152 root = tree.getroot()
153 # Some generators wrap the root in a different tag; search for <coverage>.
154 target = root if root.tag == "coverage" else root.find(".//coverage")
155 if target is None:
156 raise ValueError(f"No <coverage> element found in {path}")
157 rate = target.get("line-rate")
158 if rate is None:
159 raise ValueError(f"No line-rate attribute on <coverage> element in {path}")
160 try:
161 return float(rate) * 100
162 except ValueError as exc:
163 raise ValueError(f"Non-numeric line-rate {rate!r} in {path}") from exc
166def parse_coveralls(path: str) -> float:
167 """Read covered_percent from a Coveralls-format JSON file.
169 Args:
170 path: Path to the Coveralls JSON file to parse.
172 Returns:
173 Line coverage as a percentage in the range [0, 100].
175 Raises:
176 ValueError: If ``covered_percent`` is missing, null, or non-numeric.
177 json.JSONDecodeError: If the file is not valid JSON.
178 OSError: If the file cannot be opened.
179 """
180 with open(path, encoding="utf-8") as f:
181 data = json.load(f)
182 if "covered_percent" not in data:
183 raise ValueError(f"No 'covered_percent' field in Coveralls file: {path!r}")
184 pct = data["covered_percent"]
185 if pct is None:
186 raise ValueError(f"'covered_percent' is null in Coveralls file: {path!r}")
187 try:
188 return float(pct)
189 except (ValueError, TypeError) as exc:
190 raise ValueError(f"Non-numeric covered_percent {pct!r} in {path!r}") from exc
193def parse_istanbul(path: str) -> float:
194 """Read total.lines.pct from an Istanbul/NYC coverage-summary.json file.
196 Args:
197 path: Path to the Istanbul coverage-summary.json file to parse.
199 Returns:
200 Line coverage as a percentage in the range [0, 100].
202 Raises:
203 ValueError: If ``total``, ``total.lines``, or ``total.lines.pct`` is
204 missing, null, or non-numeric.
205 json.JSONDecodeError: If the file is not valid JSON.
206 OSError: If the file cannot be opened.
207 """
208 with open(path, encoding="utf-8") as f:
209 data = json.load(f)
210 total = data.get("total")
211 if total is None:
212 raise ValueError(f"No 'total' key in Istanbul coverage file: {path!r}")
213 lines = total.get("lines")
214 if lines is None:
215 raise ValueError(f"No 'total.lines' key in Istanbul coverage file: {path!r}")
216 pct = lines.get("pct")
217 if pct is None:
218 raise ValueError(
219 f"No 'total.lines.pct' key in Istanbul coverage file: {path!r}"
220 )
221 try:
222 return float(pct)
223 except (TypeError, ValueError) as exc:
224 raise ValueError(
225 f"Non-numeric 'total.lines.pct' value {pct!r} in {path!r}"
226 ) from exc
229# ---------------------------------------------------------------------------
230# Auto-detection
231# ---------------------------------------------------------------------------
233# Checked in priority order; first match wins.
234_CANDIDATES = [
235 ("lcov", "**/lcov.info"),
236 ("cobertura", "**/cobertura.xml"),
237 ("cobertura", "**/coverage.xml"),
238 ("coveralls", "**/coveralls.json"),
239 ("istanbul", "**/coverage-summary.json"),
240]
242# Directories that are never searched for coverage files.
243_SKIP_DIRS = frozenset(
244 {
245 "node_modules",
246 ".git",
247 "vendor",
248 "venv",
249 ".venv",
250 "site-packages",
251 "__pycache__",
252 "dist",
253 "build",
254 }
255)
258def _find_files(pattern: str, root: Path = Path(".")) -> Iterator[str]:
259 for path in root.glob(pattern):
260 # Check only the directory components relative to root (not the filename
261 # or any parent directories outside root) to avoid false positives when
262 # root itself lives inside a directory whose name matches a skip entry.
263 if not any(part in _SKIP_DIRS for part in path.relative_to(root).parts[:-1]):
264 yield str(path)
267# Parser dispatch table — defined once at module load.
268_PARSERS: dict[str, Callable[[str], float]] = {
269 "lcov": parse_lcov,
270 "cobertura": parse_cobertura,
271 "coveralls": parse_coveralls,
272 "istanbul": parse_istanbul,
273}
276def _parse(fmt: str, path: str) -> float:
277 return _PARSERS[fmt](path)
280def detect_and_parse(root: Path = Path(".")) -> float:
281 """Search the working tree for a supported coverage file and parse it.
283 Candidates are checked in priority order (see ``_CANDIDATES``). The first
284 match within the highest-priority format is used. When multiple files match
285 the same format, a warning is logged and the first result is used.
287 Args:
288 root: Directory to search. Defaults to the current working directory.
290 Returns:
291 Line coverage as a percentage in the range [0, 100].
293 Raises:
294 FileNotFoundError: If no supported coverage file is found under ``root``.
295 ValueError: If the detected file cannot be parsed.
296 OSError: If a matched file cannot be opened.
297 """
298 for fmt, pattern in _CANDIDATES:
299 matches = list(_find_files(pattern, root))
300 if len(matches) > 1:
301 logger.warning("Multiple %s files found; using %s", fmt, matches[0])
302 for path in matches:
303 logger.info("Detected %s coverage file: %s", fmt, path)
304 return _parse(fmt, path)
305 raise FileNotFoundError(
306 "No coverage file found. Provide one via the coverage-file input or "
307 "generate a supported format: lcov.info, cobertura.xml, coverage.xml, "
308 "coveralls.json, or coverage-summary.json."
309 )
312_FILENAME_TO_FORMAT: dict[str, str] = {
313 "lcov.info": "lcov",
314 "cobertura.xml": "cobertura",
315 "coverage.xml": "cobertura",
316 "coveralls.json": "coveralls",
317 "coverage-summary.json": "istanbul",
318}
321def _infer_format_from_content(path: str) -> str:
322 """Inspect file content to determine format when the filename is non-standard.
324 Files larger than 50 MB are rejected to prevent memory exhaustion. Content
325 is read in full so JSON is parsed completely (not truncated).
326 """
327 size = os.path.getsize(path)
328 if size > _MAX_FILE_BYTES:
329 raise ValueError(
330 f"Coverage file is too large to parse safely: {path!r} ({size} bytes)"
331 )
332 # Read only the opening bytes for format detection; loading the full file is
333 # deferred to the JSON path where schema inspection requires complete content.
334 with open(path, encoding="utf-8", errors="replace") as f:
335 header = f.read(4096)
336 stripped = header.lstrip()
337 if stripped.startswith("<"):
338 return "cobertura"
339 if stripped.startswith("{"):
340 with open(path, encoding="utf-8") as f:
341 content = f.read()
342 try:
343 data = json.loads(content)
344 except json.JSONDecodeError as exc:
345 raise ValueError(
346 f"Cannot determine coverage format for {path!r}: "
347 "file starts with '{' but is not valid JSON"
348 ) from exc
349 if "covered_percent" in data:
350 return "coveralls"
351 if "total" in data:
352 return "istanbul"
353 raise ValueError(
354 f"Cannot determine coverage format for {path!r}: "
355 "JSON file has neither 'covered_percent' (Coveralls) nor 'total' "
356 "(Istanbul). Is this a coverage file?"
357 )
358 # Content is neither XML nor JSON. LCOV is a line-based format with no
359 # magic byte; treat unrecognised text as LCOV and let the parser validate.
360 return "lcov"
363def infer_format(path: str) -> str:
364 """Infer format from filename, falling back to content inspection.
366 Recognised filenames (case-insensitive): ``lcov.info``, ``cobertura.xml``,
367 ``coverage.xml``, ``coveralls.json``, ``coverage-summary.json``.
368 Non-standard filenames trigger content inspection via
369 :func:`_infer_format_from_content`.
371 Args:
372 path: Path to the coverage file.
374 Returns:
375 One of ``"lcov"``, ``"cobertura"``, ``"coveralls"``, or ``"istanbul"``.
377 Raises:
378 ValueError: If the format cannot be determined from the file contents.
379 OSError: If the file cannot be opened during content inspection.
380 """
381 name = Path(path).name.lower()
382 fmt = _FILENAME_TO_FORMAT.get(name)
383 if fmt is not None:
384 return fmt
385 return _infer_format_from_content(path)
388# ---------------------------------------------------------------------------
389# Badge helpers
390# ---------------------------------------------------------------------------
393def _shields_encode(label: str) -> str:
394 """Encode a plain-text label for use in a shields.io static badge URL.
396 shields.io convention: space → _, - → --, _ → __.
397 Remaining special characters are percent-encoded as UTF-8 bytes so that
398 non-BMP Unicode (code points > U+FFFF) is encoded correctly.
399 """
400 # Escape existing - and _ before mapping space to _.
401 encoded = label.replace("-", "--").replace("_", "__").replace(" ", "_")
402 # quote() encodes everything except unreserved characters (ALPHA, DIGIT,
403 # - . _ ~) using UTF-8 percent-encoding, which correctly handles non-BMP
404 # characters that would otherwise produce an oversized hex escape.
405 return quote(encoded, safe="")
408def _shields_decode(label: str) -> str:
409 """Decode a shields.io static badge URL label back to plain text.
411 Reverses the encoding applied by _shields_encode.
412 """
413 # Protect doubled escapes before converting single ones.
414 decoded = (
415 label.replace("--", "\x00")
416 .replace("__", "\x01")
417 .replace("_", " ")
418 .replace("\x00", "-")
419 .replace("\x01", "_")
420 )
421 return unquote(decoded)
424def badge_color(pct: float) -> str:
425 """Return a shields.io color name for the given percentage.
427 Thresholds mirror jedi-knights/neospec and common open-source conventions.
429 Args:
430 pct: Coverage percentage in the range [0, 100].
432 Returns:
433 A shields.io color string: ``"brightgreen"``, ``"green"``, ``"yellow"``,
434 ``"orange"``, or ``"red"``.
435 """
436 if pct >= 90:
437 return "brightgreen"
438 if pct >= 75:
439 return "green"
440 if pct >= 60:
441 return "yellow"
442 if pct >= 40:
443 return "orange"
444 return "red"
447def badge_url(pct: float | None, label: str) -> str:
448 """Build a static shields.io badge URL for the given percentage and label.
450 When ``pct`` is ``None``, returns an ``unknown`` badge with ``lightgrey``
451 color to indicate that no coverage data is available.
453 Args:
454 pct: Coverage percentage in the range [0, 100], or ``None`` for unknown.
455 label: Plain-text badge label (e.g. ``"coverage"``). Encoded for
456 shields.io using :func:`_shields_encode`.
458 Returns:
459 A fully-formed ``https://img.shields.io/badge/…`` URL string.
460 """
461 encoded_label = _shields_encode(label)
462 if pct is None:
463 return f"https://img.shields.io/badge/{encoded_label}-unknown-lightgrey"
464 # Round first so the color threshold and the displayed value agree.
465 pct_r = round(pct, 1)
466 color = badge_color(pct_r)
467 # shields.io requires % to be percent-encoded as %25 in static badge URLs.
468 return f"https://img.shields.io/badge/{encoded_label}-{pct_r:.1f}%25-{color}"
471# Matches any shields.io static badge URL. The label group uses -- to handle
472# shields.io's double-dash escaping for literal hyphens within a label.
473_BADGE_URL_RE = re.compile(
474 r"https://img\.shields\.io/badge/(?P<label>(?:[^-]|--)*)(?P<rest>-[^)\s\"'?]+)",
475 re.IGNORECASE,
476)
478# Matches a full linked badge: [](link_url)
479# Only `alt` and `label` are named groups — they are used in substitutions.
480# The badge URL suffix and outer link URL are anonymous patterns that bound
481# the match without being referenced by name.
482_LINKED_BADGE_RE = re.compile(
483 r"\[!\[(?P<alt>[^\]]*)\]"
484 r"\(https://img\.shields\.io/badge/(?P<label>(?:[^-]|--)*)(?:-[^)\s\"'?]+)\)"
485 r"\]\([^)]+\)",
486 re.IGNORECASE,
487)
489# Matches a bare badge:  NOT preceded by [ (i.e., not the
490# inner image of an already-linked badge). Used when report_url is provided
491# to wrap the badge in a link.
492_BARE_BADGE_RE = re.compile(
493 r"(?<!\[)!\[(?P<alt>[^\]]*)\]"
494 r"\(https://img\.shields\.io/badge/(?P<label>(?:[^-]|--)*)(?:-[^)\s\"'?]+)\)",
495 re.IGNORECASE,
496)
499def _apply_linked_substitutions(
500 content: str, new_url: str, label: str, report_url: str
501) -> tuple[str, int]:
502 """Apply badge URL and outer link URL updates when report_url is provided.
504 Processes linked badges ``[](link_url)`` first, then bare
505 badges ````, wrapping them in the supplied ``report_url``.
506 A single replacer handles both passes because the two regexes expose the
507 same named groups (``alt`` and ``label``).
509 Args:
510 content: README text to transform.
511 new_url: Replacement shields.io badge URL.
512 label: Plain-text badge label to match (case-insensitive).
513 report_url: URL to use as the outer link for every matched badge.
515 Returns:
516 Tuple of (updated content, number of replacements made).
517 """
518 count = 0
520 def replace(m: re.Match) -> str:
521 nonlocal count
522 if _shields_decode(m.group("label")).lower() == label.lower():
523 count += 1
524 return f"[]({report_url})"
525 return m.group(0)
527 content = _LINKED_BADGE_RE.sub(replace, content)
528 content = _BARE_BADGE_RE.sub(replace, content)
529 return content, count
532def _apply_url_substitution(content: str, new_url: str, label: str) -> tuple[str, int]:
533 """Update only the badge URL, preserving any existing outer link structure.
535 Args:
536 content: README text to transform.
537 new_url: Replacement shields.io badge URL.
538 label: Plain-text badge label to match (case-insensitive).
540 Returns:
541 Tuple of (updated content, number of replacements made).
542 """
543 count = 0
545 def replacer(m: re.Match) -> str:
546 nonlocal count
547 if _shields_decode(m.group("label")).lower() == label.lower():
548 count += 1
549 return new_url
550 return m.group(0)
552 return _BADGE_URL_RE.sub(replacer, content), count
555def update_badge(
556 readme_path: str,
557 pct: float | None,
558 label: str,
559 report_url: str = "",
560) -> bool:
561 """Replace the matching badge URL in readme_path.
563 Locates all shields.io static badge URLs whose decoded label matches
564 ``label`` (case-insensitive) and replaces them with the URL built by
565 :func:`badge_url`. Uses an atomic write (temp file + rename) to avoid
566 corrupting the README on partial write failures.
568 When ``report_url`` is supplied the badge is written as a linked badge
569 ``[](report_url)``:
571 - Bare badges ```` are wrapped in the link.
572 - Already-linked badges ``[)(old_link)`` have both the inner
573 badge URL and the outer link URL updated.
575 When ``report_url`` is absent the behavior is unchanged from the original:
576 only the badge URL is updated, and any existing outer link is preserved.
578 Args:
579 readme_path: Path to the README file to update.
580 pct: Coverage percentage for the new badge, or ``None`` for unknown.
581 label: Plain-text badge label to match (e.g. ``"coverage"``).
582 report_url: URL to link from the badge. Empty string disables linking.
584 Returns:
585 ``True`` when at least one badge was replaced, ``False`` when no
586 matching badge was found.
588 Raises:
589 OSError: If the README cannot be read, or if the atomic write fails.
590 """
591 if report_url and not report_url.startswith(("https://", "http://")):
592 raise ValueError(
593 f"report_url must start with 'https://' or 'http://': {report_url!r}"
594 )
596 with open(readme_path, encoding="utf-8") as f:
597 content = f.read()
599 new_url = badge_url(pct, label)
601 if report_url:
602 updated, count = _apply_linked_substitutions(
603 content, new_url, label, report_url
604 )
605 else:
606 updated, count = _apply_url_substitution(content, new_url, label)
608 if count == 0:
609 return False
611 # Atomic write: close the fd immediately and open by name to avoid leaking
612 # the descriptor if open() raises after mkstemp succeeds.
613 tmp_fd, tmp_path = tempfile.mkstemp(
614 suffix=".md.tmp", dir=str(Path(readme_path).resolve().parent)
615 )
616 os.close(tmp_fd)
617 try:
618 with open(tmp_path, "w", encoding="utf-8") as f:
619 f.write(updated)
620 os.replace(tmp_path, readme_path)
621 except OSError:
622 Path(tmp_path).unlink(missing_ok=True)
623 raise
624 return True
627# ---------------------------------------------------------------------------
628# GitHub Actions output
629# ---------------------------------------------------------------------------
632def set_output(name: str, value: str) -> None:
633 """Write a GitHub Actions step output.
635 Falls back to a ``logger.info`` call when outside a runner (i.e. when
636 ``GITHUB_OUTPUT`` is not set).
638 Args:
639 name: Output variable name. Must not contain ``\\r`` or ``\\n``.
640 value: Output value. Must not contain ``\\r`` or ``\\n``.
642 Raises:
643 ValueError: If ``name`` or ``value`` contains a carriage return or
644 newline, which would corrupt the GitHub Actions output file.
645 """
646 if any(c in name or c in value for c in "\r\n"):
647 raise ValueError(
648 f"set_output name and value must not contain newlines: "
649 f"name={name!r}, value={value!r}"
650 )
651 output_file = os.environ.get("GITHUB_OUTPUT")
652 if output_file:
653 with open(output_file, "a", encoding="utf-8") as f:
654 f.write(f"{name}={value}\n")
655 else:
656 # Fallback for local testing outside of a runner.
657 logger.info("output: %s=%s", name, value)
660# ---------------------------------------------------------------------------
661# Entry point
662# ---------------------------------------------------------------------------
665def _parse_inputs(badge_label: str) -> float | None:
666 """Validate badge_label is non-empty and parse the FAIL_BELOW env var.
668 Returns the float threshold on success, or None on any validation failure
669 (error is logged before returning None).
670 """
671 if not badge_label:
672 logger.error("badge-label must not be empty")
673 return None
674 raw = os.environ.get("FAIL_BELOW", "0").strip() or "0"
675 try:
676 value = float(raw)
677 except ValueError:
678 logger.error(
679 "Invalid fail-below value: %r — must be a number between 0 and 100", raw
680 )
681 return None
682 if not 0 <= value <= 100:
683 logger.error("Invalid fail-below value: %s — must be between 0 and 100", value)
684 return None
685 return value
688def _parse_coverage_file(coverage_file: str) -> float | None:
689 """Parse an explicitly supplied coverage file. Returns None on any error."""
690 try:
691 fmt = infer_format(coverage_file)
692 logger.info("Using explicit coverage file (%s): %s", fmt, coverage_file)
693 return _parse(fmt, coverage_file)
694 except json.JSONDecodeError as exc:
695 logger.error("Failed to parse JSON coverage file: %s", exc)
696 except ET.ParseError as exc:
697 logger.error("Failed to parse XML coverage file: %s", exc)
698 except (OSError, ValueError) as exc:
699 logger.error("%s", exc)
700 return None
703def _resolve_coverage(coverage_file: str) -> float | None:
704 """Parse the coverage percentage from a file or auto-detection.
706 When coverage_file is empty, searches the workspace automatically. Raises
707 FileNotFoundError if no coverage files are found (caller decides how to
708 handle the no-data case). Returns None when a parse error occurs (the error
709 is already logged before returning).
710 """
711 if coverage_file:
712 return _parse_coverage_file(coverage_file)
713 try:
714 return detect_and_parse()
715 except FileNotFoundError:
716 raise # propagate "no coverage files in workspace" for main() to handle
717 except json.JSONDecodeError as exc:
718 logger.error("Failed to parse JSON coverage file: %s", exc)
719 except ET.ParseError as exc:
720 logger.error("Failed to parse XML coverage file: %s", exc)
721 except (OSError, ValueError) as exc:
722 logger.error("%s", exc)
723 return None
726def _update_readme_badge(
727 readme_path: str,
728 pct: float | None,
729 badge_label: str,
730 report_url: str = "",
731) -> int:
732 """Write the coverage badge to the README. Returns 0 on success, 1 on OSError."""
733 try:
734 found = update_badge(readme_path, pct, badge_label, report_url=report_url)
735 except OSError as exc:
736 logger.error("%s", exc)
737 return 1
738 if found:
739 logger.info("Badge updated in %s", readme_path)
740 else:
741 logger.warning(
742 "No '%s' badge found in %s — nothing to update", badge_label, readme_path
743 )
744 return 0
747def _warn_if_pages_unavailable(report_url: str) -> None:
748 """Emit a warning when report_url is set on a repo that may not support Pages.
750 GitHub Pages for private and internal repositories requires GitHub Enterprise
751 Cloud. When a non-public visibility is detected the caller's deploy step may
752 fail with HTTP 422, so a warning is emitted early.
754 Args:
755 report_url: The report URL passed by the user. No-op when empty.
756 """
757 if not report_url:
758 return
759 visibility = os.environ.get("REPO_VISIBILITY", "").strip().lower()
760 if visibility in ("private", "internal"):
761 logger.warning(
762 "GitHub Pages for private repositories requires GitHub Enterprise Cloud. "
763 "The deploy step may fail with HTTP 422 if your plan does not support it."
764 )
767def main() -> int:
768 """Detect coverage, update the README badge, and enforce the threshold.
770 Reads all inputs from environment variables:
772 - ``COVERAGE_FILE``: explicit path to a coverage file; auto-detects if empty.
773 - ``README_PATH``: path to the README to update (default: ``README.md``).
774 - ``BADGE_LABEL``: alt-text label of the badge to replace (default: ``coverage``).
775 - ``FAIL_BELOW``: minimum coverage percentage; ``0`` disables the check.
776 - ``REPORT_URL``: URL to link from the badge; empty disables linking.
777 - ``REPO_VISIBILITY``: ``public``, ``private``, or ``internal``; used to
778 warn when Pages may not be available.
780 Returns:
781 ``0`` on success, ``1`` on any validation or I/O failure, or when
782 coverage falls below the ``FAIL_BELOW`` threshold.
783 """
784 # Empty coverage_file triggers auto-detection.
785 coverage_file = os.environ.get("COVERAGE_FILE", "").strip()
786 readme_path = os.environ.get("README_PATH", "README.md").strip()
787 badge_label = os.environ.get("BADGE_LABEL", "Coverage").strip()
788 report_url = os.environ.get("REPORT_URL", "").strip()
789 fail_below = _parse_inputs(badge_label)
790 if fail_below is None:
791 return 1
793 _warn_if_pages_unavailable(report_url)
795 try:
796 pct = _resolve_coverage(coverage_file)
797 except FileNotFoundError:
798 logger.warning("No coverage data found — badge updated to show 'unknown'")
799 return _update_readme_badge(
800 readme_path, None, badge_label, report_url=report_url
801 )
803 if pct is None:
804 return 1
806 logger.info("Coverage: %s%%", f"{pct:.1f}")
807 set_output("coverage-percentage", f"{pct:.1f}")
809 if _update_readme_badge(readme_path, pct, badge_label, report_url=report_url):
810 return 1
812 if fail_below > 0 and pct < fail_below:
813 logger.error(
814 "Coverage %s%% is below the required threshold of %s%%",
815 f"{pct:.1f}",
816 f"{fail_below:.1f}",
817 )
818 return 1
820 return 0
823if __name__ == "__main__": # pragma: no cover
824 _configure_logging()
825 sys.exit(main())