Coverage for scripts/test_report.py: 56%

91 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-14 12:31 +0000

1#!/usr/bin/env python3 

2"""Run a test command and write an auditable Markdown report.""" 

3 

4import argparse 

5import datetime as dt 

6import hashlib 

7import json 

8import os 

9import subprocess 

10from pathlib import Path 

11 

12 

13def digest(path): 

14 return hashlib.sha256(path.read_bytes()).hexdigest()[:12] 

15 

16 

17def compact_lines(lines): 

18 numbers = sorted(set(int(line) for line in lines)) 

19 if not numbers: 19 ↛ 20line 19 didn't jump to line 20 because the condition on line 19 was never true

20 return "none" 

21 ranges = [] 

22 start = previous = numbers[0] 

23 for number in numbers[1:]: 

24 if number == previous + 1: 

25 previous = number 

26 continue 

27 ranges.append(str(start) if start == previous else f"{start}-{previous}") 

28 start = previous = number 

29 ranges.append(str(start) if start == previous else f"{start}-{previous}") 

30 return ", ".join(ranges) 

31 

32 

33def coverage_details(path, source): 

34 unavailable = { 

35 "summary": "not measured", 

36 "covered_lines": "not measured", 

37 "missing_lines": "not measured", 

38 "missing_branches": "not measured", 

39 } 

40 if not path.is_file(): 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true

41 return unavailable 

42 try: 

43 files = json.loads(path.read_text(encoding="utf-8"))["files"] 

44 except (KeyError, TypeError, ValueError, json.JSONDecodeError): 

45 return {key: "invalid coverage data" for key in unavailable} 

46 

47 source_resolved = source.resolve() 

48 match = next( 

49 ( 

50 details 

51 for filename, details in files.items() 

52 if Path(filename).resolve() == source_resolved 

53 ), 

54 None, 

55 ) 

56 if match is None: 

57 return unavailable 

58 try: 

59 summary = match["summary"] 

60 percent = float(summary["percent_covered"]) 

61 covered = int(summary["covered_lines"]) 

62 statements = int(summary["num_statements"]) 

63 covered_lines = compact_lines(match["executed_lines"]) 

64 missing_lines = compact_lines(match["missing_lines"]) 

65 missing_branches = ", ".join( 

66 f"{source_line} -> {'exit' if destination < 0 else destination}" 

67 for source_line, destination in match["missing_branches"] 

68 ) 

69 except (KeyError, TypeError, ValueError): 

70 return {key: "invalid coverage data" for key in unavailable} 

71 return { 

72 "summary": f"{percent:.1f}% ({covered}/{statements} lines)", 

73 "covered_lines": covered_lines, 

74 "missing_lines": missing_lines, 

75 "missing_branches": missing_branches or "none", 

76 } 

77 

78 

79def repository_context(environment=None): 

80 environment = os.environ if environment is None else environment 

81 server = environment.get("GITHUB_SERVER_URL") 

82 repository = environment.get("GITHUB_REPOSITORY") 

83 sha = environment.get("GITHUB_SHA") 

84 run_id = environment.get("GITHUB_RUN_ID") 

85 if server and repository: 85 ↛ 95line 85 didn't jump to line 95 because the condition on line 85 was always true

86 commit = ( 

87 f"[{sha[:12]}]({server}/{repository}/commit/{sha})" if sha else "unknown" 

88 ) 

89 ci_run = ( 

90 f"[GitHub Actions run]({server}/{repository}/actions/runs/{run_id})" 

91 if run_id 

92 else "not available" 

93 ) 

94 return commit, ci_run 

95 try: 

96 local_sha = subprocess.run( 

97 ["git", "rev-parse", "--short=12", "HEAD"], 

98 capture_output=True, 

99 text=True, 

100 check=True, 

101 ).stdout.strip() 

102 except (OSError, subprocess.CalledProcessError): 

103 local_sha = "unknown" 

104 return f"`{local_sha}`", "local run" 

105 

106 

107def main(): 

108 parser = argparse.ArgumentParser() 

109 parser.add_argument("--source", type=Path, required=True) 

110 parser.add_argument("--test", type=Path, required=True) 

111 parser.add_argument("--report", type=Path, required=True) 

112 parser.add_argument("--coverage-json", type=Path, default=Path("coverage.json")) 

113 parser.add_argument("--html-coverage-url") 

114 parser.add_argument("command", nargs=argparse.REMAINDER) 

115 args = parser.parse_args() 

116 command = args.command[1:] if args.command[:1] == ["--"] else args.command 

117 if not command: 

118 parser.error("a test command is required after --") 

119 for path in (args.source, args.test): 

120 if not path.is_file(): 

121 parser.error(f"file not found: {path}") 

122 

123 result = subprocess.run(command, capture_output=True, text=True) 

124 output = (result.stdout + result.stderr).strip() 

125 status = "PASS" if result.returncode == 0 else "FAIL" 

126 timestamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() 

127 rendered_command = " ".join(command) 

128 commit, ci_run = repository_context() 

129 coverage = coverage_details(args.coverage_json, args.source) 

130 html_coverage = ( 

131 f"[Open annotated source]({args.html_coverage_url})" 

132 if args.html_coverage_url 

133 else "not published" 

134 ) 

135 content = f"""# Test Report: `{args.source.name}` 

136 

137## Result 

138 

139| Field | Value | 

140|---|---| 

141| Status | **{status}** | 

142| Exit code | `{result.returncode}` | 

143| Run at | `{timestamp}` | 

144| Source SHA-256 | `{digest(args.source)}` | 

145| Test SHA-256 | `{digest(args.test)}` | 

146| Target source coverage | {coverage['summary']} | 

147| HTML coverage | {html_coverage} | 

148| Commit | {commit} | 

149| CI | {ci_run} | 

150 

151## Target Source Coverage Details 

152 

153- **Covered lines:** {coverage['covered_lines']} 

154- **Missing lines:** {coverage['missing_lines']} 

155- **Missing branches:** {coverage['missing_branches']} 

156 

157## Command 

158 

159```text 

160{rendered_command} 

161``` 

162 

163## Test output 

164 

165```text 

166{output or '(no output)'} 

167``` 

168""" 

169 args.report.parent.mkdir(parents=True, exist_ok=True) 

170 args.report.write_text(content, encoding="utf-8") 

171 print(f"Wrote {args.report} ({status})") 

172 raise SystemExit(result.returncode) 

173 

174 

175if __name__ == "__main__": 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true

176 main()