Coverage for src/genbadge/main.py: 85%

133 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2025-11-24 14:51 +0000

1# Authors: Sylvain MARIE <sylvain.marie@se.com> 

2# + All contributors to <https://github.com/smarie/python-genbadge> 

3# 

4# License: 3-clause BSD, <https://github.com/smarie/python-genbadge/blob/master/LICENSE> 

5try: 

6 from pathlib import Path 

7except ImportError: # pragma: no cover 

8 from pathlib2 import Path # python 2 

9 

10import click 

11 

12 

13from .utils_junit import get_test_stats, get_tests_badge 

14from .utils_coverage import get_coverage_badge, get_coverage_stats 

15from .utils_flake8 import get_flake8_stats, get_flake8_badge 

16 

17try: 

18 FileNotFoundError 

19except NameError: 

20 FileNotFoundError = IOError 

21 

22 

23INFILE_HELP_TMP = "An alternate %s file to read. '-' is supported and means <stdin>." 

24OUTFILE_BADGE_HELP = ("An alternate SVG badge file to write to. '-' is supported and means <stdout>. Note that in this " 

25 "case no other message will be printed to <stdout>. In particular the verbose flag will have no " 

26 "effect.") 

27NAME_HELP = ("An alternate SVG badge text name to display on the left-hand side of the badge.") 

28WITH_NAME_HELP = ("Indicates if a badge should be generated with or without the left-hand side of the badge.") 

29SHIELDS_HELP = ("Indicates if badges should be generated using the shields.io HTTP API (default) or the local SVG file " 

30 "template included.") 

31VERBOSE_HELP = ("Use this flag to print details to stdout during the badge generation process. Note that this flag has " 

32 "no effect when '-' is used as output, since the badge is written to <stdout>. It also has no effect " 

33 "when the silent flag `-s` is used.") 

34SILENT_HELP = ("When this flag is active nothing will be written to stdout. Note that this flag has no effect when '-' " 

35 "is used as the output file.") 

36 

37 

38@click.group(invoke_without_command=True) 

39@click.pass_context 

40def genbadge(ctx): 

41 """ 

42 Commandline utility to generate badges. 

43 To get help on each command use: 

44 

45 genbadge <cmd> --help 

46 

47 """ 

48 if ctx.invoked_subcommand is None: 

49 click.echo(ctx.get_help()) 

50 ctx.exit(0) 

51 

52 

53@genbadge.command(name="tests", 

54 short_help="Generate a badge for the test results (e.g. from a junit.xml).") 

55@click.option('-i', '--input-file', type=click.File('rt'), help=INFILE_HELP_TMP % "test results XML") 

56@click.option('-o', '--output-file', type=click.File('wt'), help=OUTFILE_BADGE_HELP) 

57@click.option('-n', '--name', type=str, default="tests", help=NAME_HELP) 

58@click.option('-t', '--threshold', type=float, 

59 help="An optional success percentage threshold to use. The command will fail with exit code 1 if the" 

60 "actual success percentage is strictly less than the provided value.") 

61@click.option('--withname/--noname', type=bool, default=True, help=WITH_NAME_HELP) 

62@click.option('-w/-l', '--webshields/--local', type=bool, default=True, help=SHIELDS_HELP) 

63@click.option('-v', '--verbose', type=bool, default=False, is_flag=True, help=VERBOSE_HELP) 

64@click.option('-s', '--silent', type=bool, default=False, is_flag=True, help=SILENT_HELP) 

65def gen_tests_badge( 

66 input_file=None, 

67 output_file=None, 

68 name=None, 

69 threshold=None, 

70 withname=None, 

71 webshields=None, 

72 verbose=None, 

73 silent=None 

74): 

75 """ 

76 This command generates a badge for the test results, from an XML file in the 

77 junit format. Such a file can be for example generated from python pytest 

78 using the --junitxml flag, or from java junit. 

79 

80 By default the input file is the relative `./reports/junit/junit.xml` and 

81 the output file is `./tests-badge.svg`. You can change these settings with 

82 the `-i/--input_file` and `-o/--output-file` options. 

83 

84 By default the badge will have the name "tests" as the left-hand side text. 

85 You can change these settings with the `-n/--name` option. The left-hand side 

86 text can be left blank with `-n ""` or have the left-hand side of the badge 

87 completely removed by passing `--noname`. 

88 

89 You can use the verbose flag `-v/--verbose` to display information on the 

90 input file contents, for verification. 

91 

92 The resulting badge will by default look like this: [tests | 6/12] 

93 where 6 is the number of tests that have run successfully, and 12 is the 

94 total number of tests minus the number of skipped tests. When all tests 

95 pass with success, the badge simply shows the number of tests [tests | 12]. 

96 You can change the appearance of the badge with the --format option (not 

97 implemented, todo). 

98 

99 The success percentage is defined as 6/12 = 50.0%. You can use the 

100 `-t/--threshold` flag to setup a minimum success percentage required. If the 

101 success percentage is below the threshold, an error will be raised and the 

102 badge will not be generated. 

103 """ 

104 # Process i/o files 

105 input_file, input_file_path = _process_infile(input_file, "reports/junit/junit.xml") 

106 output_file, output_file_path, is_stdout = _process_outfile(output_file, "tests-badge.svg") 

107 

108 # First retrieve the success percentage from the junit xml 

109 try: 

110 test_stats = get_test_stats(junit_xml_file=input_file) 

111 except FileNotFoundError: 

112 raise click.exceptions.FileError(input_file, hint="File not found") 

113 

114 if not silent and verbose and not is_stdout: 

115 click.echo("""Test statistics parsed successfully from %r 

116 - Nb tests: Total (%s) = Success (%s) + Skipped (%s) + Failed (%s) + Errors (%s) 

117 - Success percentage: %.2f%% (%s / %s) (Skipped tests are excluded) 

118""" % (input_file_path, test_stats.total_with_skipped, test_stats.success, test_stats.skipped, test_stats.failed, 

119 test_stats.errors, test_stats.success_percentage, test_stats.success, test_stats.total_without_skipped)) 

120 

121 # sanity check 

122 if test_stats.total_with_skipped != test_stats.success + test_stats.skipped + test_stats.failed + test_stats.errors: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 raise click.exceptions.ClickException( 

124 "Inconsistent junit results: the sum of all kind of tests is not equal to the total. Please report this " 

125 "issue if you think your file is correct. Details: %r" % test_stats 

126 ) 

127 

128 # Validate against the threshold 

129 if threshold is not None and test_stats.success_percentage < threshold: 

130 raise click.exceptions.ClickException( 

131 "Success percentage %s%% is strictly lower than required threshold %s%%" 

132 % (float(test_stats.success_percentage), threshold) 

133 ) 

134 

135 # Set badge name 

136 clear_left_txt = False 

137 if not withname: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 name = "" # removes left side of badge 

139 elif not name.strip(): 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 name = "###" # blank text to replace 

141 clear_left_txt = True # keep left side of badge but remove text 

142 

143 # Generate the badge 

144 badge = get_tests_badge(test_stats, name) 

145 badge.write_to( 

146 output_file if is_stdout else output_file_path, 

147 use_shields=webshields, 

148 clear_left_txt=clear_left_txt 

149 ) 

150 

151 if not silent and not is_stdout: 

152 click.echo("SUCCESS - Tests badge created: %r" % str(output_file_path)) 

153 

154 

155@genbadge.command(name="coverage", 

156 short_help="Generate a badge for the coverage results (e.g. from a coverage.xml).") 

157@click.option('-i', '--input-file', type=click.File('rt'), help=INFILE_HELP_TMP % "coverage results XML") 

158@click.option('-o', '--output-file', type=click.File('wt'), help=OUTFILE_BADGE_HELP) 

159@click.option('-n', '--name', type=str, default="coverage", help=NAME_HELP) 

160@click.option('--withname/--noname', type=bool, default=True, help=WITH_NAME_HELP) 

161@click.option('-w/-l', '--webshields/--local', type=bool, default=True, help=SHIELDS_HELP) 

162@click.option('-v', '--verbose', type=bool, default=False, is_flag=True, help=VERBOSE_HELP) 

163@click.option('-s', '--silent', type=bool, default=False, is_flag=True, help=SILENT_HELP) 

164def gen_coverage_badge( 

165 input_file=None, 

166 output_file=None, 

167 name=None, 

168 withname=None, 

169 webshields=None, 

170 verbose=None, 

171 silent=None 

172): 

173 """ 

174 This command generates a badge for the coverage results, from an XML file in 

175 the 'coverage' format. Such a file can be for example generated using the 

176 python `coverage` tool, or java `cobertura`. 

177 

178 By default the input file is the relative `./reports/coverage/coverage.xml` 

179 and the output file is `./coverage-badge.svg`. You can change these settings 

180 with the `-i/--input_file` and `-o/--output-file` options. 

181  

182 By default the badge will have the name "coverage" as the left-hand side text. 

183 You can change these settings with the `-n/--name` option. The left-hand side 

184 text can be left blank with `-n ""` or have the left-hand side of the badge 

185 completely removed by passing `--noname`. 

186 

187 You can use the verbose flag `-v/--verbose` to display information on the 

188 input file contents, for verification. 

189 

190 The resulting badge will by default look like this: [coverage | 98.1%] where 

191 98.1 is the total coverage, obtained from the branch and line coverages 

192 using the formula 

193 

194 (nb_lines_covered + nb_branches_covered) / (nb_lines / nb_branches) 

195 

196 and multiplying this by 100. 

197 """ 

198 # Process i/o files 

199 input_file, input_file_path = _process_infile(input_file, "reports/coverage/coverage.xml") 

200 output_file, output_file_path, is_stdout = _process_outfile(output_file, "coverage-badge.svg") 

201 

202 # First retrieve the coverage info from the coverage xml 

203 try: 

204 cov_stats = get_coverage_stats(coverage_xml_file=input_file) 

205 except FileNotFoundError: 

206 raise click.exceptions.FileError(input_file, hint="File not found") 

207 

208 if not silent and verbose and not is_stdout: 

209 click.echo("""Coverage results parsed successfully from %(ifp)r 

210 - Branch coverage: %(bcp).2f%% (%(bc)s/%(bv)s) 

211 - Line coverage: %(lcp).2f%% (%(lc)s/%(lv)s) 

212 - Total coverage: %(tcp).2f%% ((%(bc)s+%(lc)s)/(%(bv)s+%(lv)s)) 

213""" % dict(ifp=input_file_path, tcp=cov_stats.total_coverage, 

214 bcp=cov_stats.branch_coverage, bc=cov_stats.branches_covered, bv=cov_stats.branches_valid, 

215 lcp=cov_stats.line_coverage, lc=cov_stats.lines_covered, lv=cov_stats.lines_valid)) 

216 

217 # Set badge name 

218 clear_left_txt = False 

219 if not withname: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 name = "" # removes left side of badge 

221 elif not name.strip(): 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 name = "###" # blank text to replace 

223 clear_left_txt = True # keep left side of badge but remove text 

224 

225 # Generate the badge 

226 badge = get_coverage_badge(cov_stats, name) 

227 badge.write_to( 

228 output_file if is_stdout else output_file_path, 

229 use_shields=webshields, 

230 clear_left_txt=clear_left_txt 

231 ) 

232 

233 if not silent and not is_stdout: 

234 click.echo("SUCCESS - Coverage badge created: %r" % str(output_file_path)) 

235 

236 

237@genbadge.command(name="flake8", 

238 short_help="Generate a badge for the flake8 results (e.g. from a flake8stats.txt file).") 

239@click.option('-i', '--input-file', type=click.File('rt'), help=INFILE_HELP_TMP % "flake8 results TXT") 

240@click.option('-o', '--output-file', type=click.File('wt'), help=OUTFILE_BADGE_HELP) 

241@click.option('-n', '--name', type=str, default="flake8", help=NAME_HELP) 

242@click.option('--withname/--noname', type=bool, default=True, help=WITH_NAME_HELP) 

243@click.option('-w/-l', '--webshields/--local', type=bool, default=True, help=SHIELDS_HELP) 

244@click.option('-v', '--verbose', type=bool, default=False, is_flag=True, help=VERBOSE_HELP) 

245@click.option('-s', '--silent', type=bool, default=False, is_flag=True, help=SILENT_HELP) 

246def gen_flake8_badge( 

247 input_file=None, 

248 output_file=None, 

249 name=None, 

250 withname=None, 

251 webshields=None, 

252 verbose=None, 

253 silent=None 

254): 

255 """ 

256 This command generates a badge for the flake8 results, from a flake8stats.txt 

257 file. Such a file can be generated from python `flake8` using the 

258 --statistics flag. 

259 

260 By default the input file is the relative `./reports/flake8/flake8stats.txt` 

261 and the output file is `./flake8-badge.svg`. You can change these settings 

262 with the `-i/--input_file` and `-o/--output-file` options. 

263 

264 By default the badge will have the name "flake8" as the left-hand side text. 

265 You can change these settings with the `-n/--name` option. The left-hand side 

266 text can be left blank with `-n ""` or have the left-hand side of the badge 

267 completely removed by passing `--noname`. 

268 

269 You can use the verbose flag `-v/--verbose` to display information on the 

270 input file contents, for verification. 

271 

272 The resulting badge will by default look like this: [flake8 | 6 C, 0 W, 5 I] 

273 where 6, 0, 5 denote the number of critical issues, warnings, and 

274 information messages respectively. These severity levels are determined by 

275 the flake8-html plugin so as to match the colors in the HTML report. You can 

276 change the appearance of the badge with the --format option (not 

277 implemented, todo). 

278 """ 

279 # Process i/o files 

280 input_file, input_file_path = _process_infile(input_file, "reports/flake8/flake8stats.txt") 

281 output_file, output_file_path, is_stdout = _process_outfile(output_file, "flake8-badge.svg") 

282 

283 # First retrieve the success percentage from the junit xml 

284 try: 

285 flake8_stats = get_flake8_stats(flake8_stats_file=input_file) 

286 except FileNotFoundError: 

287 raise click.exceptions.FileError(input_file, hint="File not found") 

288 

289 if not silent and verbose and not is_stdout: 

290 click.echo("""Flake8 statistics parsed successfully from %r 

291 - Total (%s) = Critical (%s) + Warning (%s) + Info (%s) 

292""" % (input_file_path, flake8_stats.nb_total, flake8_stats.nb_critical, flake8_stats.nb_warning, flake8_stats.nb_info)) 

293 

294 # Set badge name 

295 clear_left_txt = False 

296 if not withname: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 name = "" # removes left side of badge 

298 elif not name.strip(): 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 name = "###" # blank text to replace 

300 clear_left_txt = True # keep left side of badge but remove text 

301 

302 # Generate the badge 

303 badge = get_flake8_badge(flake8_stats, name) 

304 badge.write_to( 

305 output_file if is_stdout else output_file_path, 

306 use_shields=webshields, 

307 clear_left_txt=clear_left_txt 

308 ) 

309 

310 if not silent and not is_stdout: 

311 click.echo("SUCCESS - Flake8 badge created: %r" % str(output_file_path)) 

312 

313 

314def _process_infile(input_file, default_in_file): 

315 """Common in file processor""" 

316 

317 if input_file is None: 

318 input_file = default_in_file 

319 

320 if isinstance(input_file, str): 

321 input_file_path = Path(input_file).absolute().as_posix() 

322 else: 

323 input_file_path = getattr(input_file, "name", "<stdin>") 

324 

325 return input_file, input_file_path 

326 

327 

328def _process_outfile(output_file, default_out_file): 

329 """Common out file processor""" 

330 

331 is_stdout = False 

332 if output_file is None: 

333 output_file_path = Path(default_out_file).absolute() 

334 elif isinstance(output_file, str): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 output_file_path = Path(output_file).absolute() 

336 # special case of a directory 

337 if output_file_path.is_dir(): 

338 output_file_path = output_file_path / default_out_file 

339 else: 

340 output_file_path = getattr(output_file, "name", "<stdout>") 

341 if output_file_path == "<stdout>": 

342 is_stdout = True 

343 else: 

344 output_file_path = Path(output_file_path).absolute() 

345 

346 if not is_stdout: 

347 output_file_path = output_file_path.as_posix() 

348 

349 return output_file, output_file_path, is_stdout 

350 

351 

352if __name__ == '__main__': 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 genbadge()