Coverage for bzfs_main/util/generate_badge.py: 100%

64 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-21 12:39 +0000

1#!/usr/bin/env python3 

2 

3# Copyright 2024 Wolfgang Hoschek AT mac DOT com 

4# 

5# Licensed under the Apache License, Version 2.0 (the "License"); 

6# you may not use this file except in compliance with the License. 

7# You may obtain a copy of the License at 

8# 

9# http://www.apache.org/licenses/LICENSE-2.0 

10# 

11# Unless required by applicable law or agreed to in writing, software 

12# distributed under the License is distributed on an "AS IS" BASIS, 

13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

14# See the License for the specific language governing permissions and 

15# limitations under the License. 

16 

17# Inline script metadata conforming to https://packaging.python.org/specifications/inline-script-metadata 

18# /// script 

19# requires-python = ">=3.9" 

20# dependencies = [] 

21# /// 

22# 

23""" 

24Given a label, text and color, generate a corresponding static shields.io SVG badge. 

25The label is optional (can be empty). 

26 

27Download SVG badge from shields.io when available, otherwise fall back to local SVG generation. 

28Has zero dependencies beyond the Python standard library. 

29""" 

30 

31from __future__ import ( 

32 annotations, 

33) 

34import argparse 

35import html 

36import sys 

37import urllib.parse 

38import urllib.request 

39from pathlib import ( 

40 Path, 

41) 

42from typing import ( 

43 Final, 

44) 

45 

46_DEFAULT_COLOR: Final[str] = "#007ec6" # blue 

47 

48 

49############################################################################# 

50def _argument_parser() -> argparse.ArgumentParser: 

51 cli = argparse.ArgumentParser( 

52 description="Given a label, text and color, generate a corresponding static shields.io SVG badge. The label is " 

53 "optional (can be empty). Download SVG badge from shields.io when available, otherwise fall back to local SVG " 

54 "generation.", 

55 allow_abbrev=False, 

56 formatter_class=argparse.RawTextHelpFormatter, 

57 ) 

58 cli.add_argument( 

59 "--left", 

60 default="", 

61 metavar="STRING", 

62 help="Left text aka label (can be empty). Example: 'coverage'. Default is '%(default)s'.", 

63 ) 

64 cli.add_argument( 

65 "--right", 

66 required=True, 

67 metavar="STRING", 

68 help="Right text. Example: '99.53%%'.", 

69 ) 

70 cli.add_argument( 

71 "--color", 

72 default=_DEFAULT_COLOR, 

73 metavar="STRING", 

74 help="Background color for right text. Example: '#4b0'. Default is '%(default)s'. " 

75 "See https://github.com/badges/shields/tree/master/badge-maker#colors", 

76 ) 

77 cli.add_argument( 

78 "--output", 

79 required=True, 

80 metavar="PATH", 

81 help="Output path for generated SVG file. Example: coverage.svg", 

82 ) 

83 cli.add_argument( 

84 "--timeout", 

85 default=30, 

86 type=float, 

87 metavar="FLOAT", 

88 help="Timeout[secs] for https download request. Default is '%(default)s'. Specify 0 to bypass download attempt and " 

89 "force local SVG generation.", 

90 ) 

91 return cli 

92 

93 

94def main() -> None: 

95 """API for command line clients.""" 

96 args: argparse.Namespace = _argument_parser().parse_args() 

97 generate_badge(args.left, args.right, args.color, args.output, args.timeout) 

98 

99 

100def generate_badge(left_txt: str, right_txt: str, color: str, output_file: str, timeout: float = 30) -> None: 

101 """Writes an SVG badge for the given text; ``left_txt`` can be empty.""" 

102 if not color: 

103 color = _DEFAULT_COLOR 

104 try: 

105 if timeout <= 0: 

106 raise ValueError("dummy") 

107 svg: str = _download_svg_badge(left_txt, right_txt, color, timeout=timeout) 

108 msg = "Successfully downloaded badge" 

109 except Exception: # no network connectivity (or other error): produce badge locally 

110 svg = _build_svg_badge(left_txt, right_txt, color) 

111 msg = "Successfully built badge locally" 

112 output_path = Path(output_file) 

113 output_path.parent.mkdir(parents=True, exist_ok=True) 

114 output_path.write_text(svg, encoding="utf-8") 

115 print(f"{msg} '{left_txt}' into {output_file}", file=sys.stderr) 

116 

117 

118def _download_svg_badge(left_txt: str, right_txt: str, color: str, timeout: float) -> str: 

119 """Downloads a pretty SVG badge from shields.io.""" 

120 quoted_left_txt = urllib.parse.quote(left_txt, safe="") 

121 quoted_right_txt = urllib.parse.quote(right_txt, safe="") 

122 quoted_color = urllib.parse.quote(color, safe="") 

123 url = f"https://img.shields.io/badge/{quoted_left_txt}-{quoted_right_txt}-{quoted_color}.svg" 

124 request = urllib.request.Request(url, headers={"User-Agent": "curl/8.7.1"}) 

125 with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - fixed HTTPS origin 

126 svg: str = response.read().decode("utf-8") 

127 if not svg.lstrip().startswith("<svg"): 

128 raise ValueError(f"Downloaded badge is not SVG: {url}") 

129 return svg 

130 

131 

132def _build_svg_badge(left_txt: str, right_txt: str, color: str) -> str: 

133 """Locally creates a basic Shields-compatible SVG without requiring network connectivity.""" 

134 

135 def _text_width(text: str) -> int: # Returns a simple text segment width with padding 

136 return max(10, 10 + round(sum(4 if char in " .,:;|!ilI'`" else 7.2 for char in text))) 

137 

138 left_width = _text_width(left_txt) if left_txt else 0 

139 right_width = _text_width(right_txt) 

140 width = left_width + right_width 

141 left_x = left_width / 2 

142 right_x = left_width + right_width / 2 

143 left = html.escape(left_txt, quote=True) 

144 right = html.escape(right_txt, quote=True) 

145 title = html.escape(f"{left_txt}: {right_txt}", quote=True) 

146 color = html.escape(color, quote=True) 

147 height = 20 

148 gradient_id = "badge-shine-gradient" 

149 clip_path_id = "badge-rounded-clip" 

150 

151 return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" role="img" aria-label="{title}"> 

152<title>{title}</title> 

153<linearGradient id="{gradient_id}" x2="0" y2="100%"> 

154 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> 

155 <stop offset="1" stop-opacity=".1"/> 

156</linearGradient> 

157<clipPath id="{clip_path_id}"><rect width="{width}" height="{height}" rx="3" fill="#fff"/></clipPath> 

158<g clip-path="url(#{clip_path_id})"> 

159 <rect width="{left_width}" height="{height}" fill="#555"/> 

160 <rect x="{left_width}" width="{right_width}" height="{height}" fill="{color}"/> 

161 <rect width="{width}" height="{height}" fill="url(#{gradient_id})"/> 

162</g> 

163<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> 

164 <text x="{left_x:.1f}" y="15" fill="#010101" fill-opacity=".3">{left}</text> 

165 <text x="{left_x:.1f}" y="14">{left}</text> 

166 <text x="{right_x:.1f}" y="15" fill="#010101" fill-opacity=".3">{right}</text> 

167 <text x="{right_x:.1f}" y="14">{right}</text> 

168</g> 

169</svg> 

170""" 

171 

172 

173############################################################################# 

174if __name__ == "__main__": 

175 main()