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

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. 

5 

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""" 

12 

13from __future__ import annotations 

14 

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 

25 

26# --------------------------------------------------------------------------- 

27# Logging 

28# --------------------------------------------------------------------------- 

29 

30logger = logging.getLogger(__name__) 

31 

32 

33class _GitHubActionsFormatter(logging.Formatter): 

34 """Format log records as GitHub Actions workflow commands. 

35 

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 """ 

39 

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 

47 

48 

49def _configure_logging() -> None: 

50 """Attach a stdout handler with the GitHub Actions formatter to the module logger. 

51 

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) 

59 

60 

61# --------------------------------------------------------------------------- 

62# Parsers 

63# --------------------------------------------------------------------------- 

64 

65 

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 

74 

75 

76def parse_lcov(path: str) -> float: 

77 """Sum LF (lines found) and LH (lines hit) records across all source files. 

78 

79 Args: 

80 path: Path to the LCOV file to parse. 

81 

82 Returns: 

83 Line coverage as a percentage in the range [0, 100]. 

84 

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 

102 

103 

104_MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB 

105 

106 

107def _check_xml_safety(path: str) -> None: 

108 """Raise ValueError if the XML file contains a DOCTYPE declaration. 

109 

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). 

113 

114 Args: 

115 path: Path to the XML file to inspect. 

116 

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 ) 

127 

128 

129def parse_cobertura(path: str) -> float: 

130 """Read the line-rate attribute from the root coverage element (0–1 scale). 

131 

132 Args: 

133 path: Path to the Cobertura XML file to parse. 

134 

135 Returns: 

136 Line coverage as a percentage in the range [0, 100]. 

137 

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 

164 

165 

166def parse_coveralls(path: str) -> float: 

167 """Read covered_percent from a Coveralls-format JSON file. 

168 

169 Args: 

170 path: Path to the Coveralls JSON file to parse. 

171 

172 Returns: 

173 Line coverage as a percentage in the range [0, 100]. 

174 

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 

191 

192 

193def parse_istanbul(path: str) -> float: 

194 """Read total.lines.pct from an Istanbul/NYC coverage-summary.json file. 

195 

196 Args: 

197 path: Path to the Istanbul coverage-summary.json file to parse. 

198 

199 Returns: 

200 Line coverage as a percentage in the range [0, 100]. 

201 

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 

227 

228 

229# --------------------------------------------------------------------------- 

230# Auto-detection 

231# --------------------------------------------------------------------------- 

232 

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] 

241 

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) 

256 

257 

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) 

265 

266 

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} 

274 

275 

276def _parse(fmt: str, path: str) -> float: 

277 return _PARSERS[fmt](path) 

278 

279 

280def detect_and_parse(root: Path = Path(".")) -> float: 

281 """Search the working tree for a supported coverage file and parse it. 

282 

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. 

286 

287 Args: 

288 root: Directory to search. Defaults to the current working directory. 

289 

290 Returns: 

291 Line coverage as a percentage in the range [0, 100]. 

292 

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 ) 

310 

311 

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} 

319 

320 

321def _infer_format_from_content(path: str) -> str: 

322 """Inspect file content to determine format when the filename is non-standard. 

323 

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" 

361 

362 

363def infer_format(path: str) -> str: 

364 """Infer format from filename, falling back to content inspection. 

365 

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`. 

370 

371 Args: 

372 path: Path to the coverage file. 

373 

374 Returns: 

375 One of ``"lcov"``, ``"cobertura"``, ``"coveralls"``, or ``"istanbul"``. 

376 

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) 

386 

387 

388# --------------------------------------------------------------------------- 

389# Badge helpers 

390# --------------------------------------------------------------------------- 

391 

392 

393def _shields_encode(label: str) -> str: 

394 """Encode a plain-text label for use in a shields.io static badge URL. 

395 

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="") 

406 

407 

408def _shields_decode(label: str) -> str: 

409 """Decode a shields.io static badge URL label back to plain text. 

410 

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) 

422 

423 

424def badge_color(pct: float) -> str: 

425 """Return a shields.io color name for the given percentage. 

426 

427 Thresholds mirror jedi-knights/neospec and common open-source conventions. 

428 

429 Args: 

430 pct: Coverage percentage in the range [0, 100]. 

431 

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" 

445 

446 

447def badge_url(pct: float | None, label: str) -> str: 

448 """Build a static shields.io badge URL for the given percentage and label. 

449 

450 When ``pct`` is ``None``, returns an ``unknown`` badge with ``lightgrey`` 

451 color to indicate that no coverage data is available. 

452 

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`. 

457 

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}" 

469 

470 

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) 

477 

478# Matches a full linked badge: [![alt](badge_url)](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) 

488 

489# Matches a bare badge: ![alt](badge_url) 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) 

497 

498 

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. 

503 

504 Processes linked badges ``[![alt](badge_url)](link_url)`` first, then bare 

505 badges ``![alt](badge_url)``, 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``). 

508 

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. 

514 

515 Returns: 

516 Tuple of (updated content, number of replacements made). 

517 """ 

518 count = 0 

519 

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"[![{m.group('alt')}]({new_url})]({report_url})" 

525 return m.group(0) 

526 

527 content = _LINKED_BADGE_RE.sub(replace, content) 

528 content = _BARE_BADGE_RE.sub(replace, content) 

529 return content, count 

530 

531 

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. 

534 

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). 

539 

540 Returns: 

541 Tuple of (updated content, number of replacements made). 

542 """ 

543 count = 0 

544 

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) 

551 

552 return _BADGE_URL_RE.sub(replacer, content), count 

553 

554 

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. 

562 

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. 

567 

568 When ``report_url`` is supplied the badge is written as a linked badge 

569 ``[![label](badge_url)](report_url)``: 

570 

571 - Bare badges ``![label](...)`` are wrapped in the link. 

572 - Already-linked badges ``[![label](...))(old_link)`` have both the inner 

573 badge URL and the outer link URL updated. 

574 

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. 

577 

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. 

583 

584 Returns: 

585 ``True`` when at least one badge was replaced, ``False`` when no 

586 matching badge was found. 

587 

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 ) 

595 

596 with open(readme_path, encoding="utf-8") as f: 

597 content = f.read() 

598 

599 new_url = badge_url(pct, label) 

600 

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) 

607 

608 if count == 0: 

609 return False 

610 

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 

625 

626 

627# --------------------------------------------------------------------------- 

628# GitHub Actions output 

629# --------------------------------------------------------------------------- 

630 

631 

632def set_output(name: str, value: str) -> None: 

633 """Write a GitHub Actions step output. 

634 

635 Falls back to a ``logger.info`` call when outside a runner (i.e. when 

636 ``GITHUB_OUTPUT`` is not set). 

637 

638 Args: 

639 name: Output variable name. Must not contain ``\\r`` or ``\\n``. 

640 value: Output value. Must not contain ``\\r`` or ``\\n``. 

641 

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) 

658 

659 

660# --------------------------------------------------------------------------- 

661# Entry point 

662# --------------------------------------------------------------------------- 

663 

664 

665def _parse_inputs(badge_label: str) -> float | None: 

666 """Validate badge_label is non-empty and parse the FAIL_BELOW env var. 

667 

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 

686 

687 

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 

701 

702 

703def _resolve_coverage(coverage_file: str) -> float | None: 

704 """Parse the coverage percentage from a file or auto-detection. 

705 

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 

724 

725 

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 

745 

746 

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. 

749 

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. 

753 

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 ) 

765 

766 

767def main() -> int: 

768 """Detect coverage, update the README badge, and enforce the threshold. 

769 

770 Reads all inputs from environment variables: 

771 

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. 

779 

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 

792 

793 _warn_if_pages_unavailable(report_url) 

794 

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 ) 

802 

803 if pct is None: 

804 return 1 

805 

806 logger.info("Coverage: %s%%", f"{pct:.1f}") 

807 set_output("coverage-percentage", f"{pct:.1f}") 

808 

809 if _update_readme_badge(readme_path, pct, badge_label, report_url=report_url): 

810 return 1 

811 

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 

819 

820 return 0 

821 

822 

823if __name__ == "__main__": # pragma: no cover 

824 _configure_logging() 

825 sys.exit(main())