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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-21 12:39 +0000
1#!/usr/bin/env python3
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.
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).
27Download SVG badge from shields.io when available, otherwise fall back to local SVG generation.
28Has zero dependencies beyond the Python standard library.
29"""
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)
46_DEFAULT_COLOR: Final[str] = "#007ec6" # blue
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
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)
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)
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
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."""
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)))
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"
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"""
173#############################################################################
174if __name__ == "__main__":
175 main()