Files
anybadge/anybadge/badge.py
Jon Grace-Cox 9d4470a1a0 Allow badges with no value or no label (#89)
This commit allows badges to be generated with only a label or only
a value.

Badges must provide at least a label or a value, so it is not valid
to create a badge with no label and no value. This will result in
a `ValueError` being raised.
2024-12-30 06:53:33 -05:00

799 lines
32 KiB
Python

import os
from collections import OrderedDict
from pathlib import Path
from typing import Dict, Type, Optional, Union
from . import config
from .colors import Color
from .exceptions import UnknownBadgeTemplate
from .helpers import _get_approx_string_width
# Try and obtain packaging package to support version comparison.
from .templates import get_template
from packaging.version import Version
class Badge:
"""
Badge class used to generate badges.
Args:
label(str): Badge label text.
value(str): Badge value text.
font_name(str, optional): Name of font to use.
font_size(int, optional): Font size.
num_padding_chars(float, optional): Number of padding characters to use to give extra
space around text.
num_label_padding_chars(float, optional): Number of padding characters to use to give extra
space around label text.
num_value_padding_chars(float, optional): Number of padding characters to use to give extra
space around value text.
template(str, optional): String containing the SVG template. This should be valid SVG
file content with place holders for variables to be populated during rendering.
style(str, optional): Style of badge to create. This will make anybadge render a badge in a
different style. Valid values are "gitlab-scoped", "default". Default is "default".
value_prefix(str, optional): Prefix to be placed before value.
value_suffix(str, optional): Suffix to be placed after value.
thresholds(dict, optional): A dictionary containing thresholds used to select badge
color based on the badge value.
default_color(str, optional): Badge color as a name or as an HTML color code.
use_max_when_value_exceeds(bool, optional): Choose whether to use the maximum threshold
value when the badge value exceeds the top threshold. Default is True.
value_format(str, optional) String with formatting to be used to format the value text.
text_color(str, optional): Text color as a name or as an HTML color code.
semver(bool, optional): Used to indicate that the value is a semantic version number.
Examples:
Create a simple green badge:
>>> badge = Badge('label', 123, default_color='green')
Write a badge to file, overwriting any existing file:
>>> badge = Badge('label', 123, default_color='green')
>>> badge.write_badge('demo.svg', overwrite=True)
Here are a number of examples showing thresholds, since there
are certain situations that may not be obvious:
>>> badge = Badge('pipeline', 'passing', thresholds={'passing': 'green', 'failing': 'red'})
>>> badge.badge_color
'green'
2.32 is not <2
2.32 is < 4, so 2.32 yields orange
>>> badge = Badge('pylint', 2.32, thresholds={2: 'red',
... 4: 'orange',
... 8: 'yellow',
... 10: 'green'})
>>> badge.badge_color
'orange'
8 is not <8
8 is <4, so 8 yields orange
>>> badge = Badge('pylint', 8, thresholds={2: 'red',
... 4: 'orange',
... 8: 'yellow',
... 10: 'green'})
>>> badge.badge_color
'green'
10 is not <8, but use_max_when_value_exceeds defaults to
True, so 10 yields green
>>> badge = Badge('pylint', 11, thresholds={2: 'red',
... 4: 'orange',
... 8: 'yellow',
... 10: 'green'})
>>> badge.badge_color
'green'
11 is not <10, and use_max_when_value_exceeds is set to
False, so 11 yields the default color '#4c1'
>>> badge = Badge('pylint', 11, use_max_when_value_exceeds=False,
... thresholds={2: 'red', 4: 'orange', 8: 'yellow',
... 10: 'green'})
>>> badge.badge_color
'#4c1'
"""
#: Singleton variable to track current max mask_id. This is used by _get_next_mask_str class method.
mask_id: int
def __init__(
self,
label,
value,
font_name: Optional[str] = None,
font_size: Optional[int] = None,
num_padding_chars: Optional[int] = None,
num_label_padding_chars: Optional[float] = None,
num_value_padding_chars: Optional[float] = None,
template: Optional[Union[Path, str]] = None,
style: Optional[str] = None,
value_prefix: Optional[str] = "",
value_suffix: Optional[str] = "",
thresholds: Optional[Dict[float, str]] = None,
default_color: Optional[str] = None,
use_max_when_value_exceeds: Optional[bool] = True,
value_format: Optional[str] = None,
text_color: Optional[str] = None,
semver: Optional[bool] = False,
):
"""Constructor for Badge class."""
# Set defaults if values were not passed
if not font_name:
font_name = config.DEFAULT_FONT
if not font_size:
font_size = config.DEFAULT_FONT_SIZE
if num_label_padding_chars is None:
if num_padding_chars is None:
num_label_padding_chars = config.NUM_PADDING_CHARS
else:
num_label_padding_chars = num_padding_chars
if num_value_padding_chars is None:
if num_padding_chars is None:
num_value_padding_chars = config.NUM_PADDING_CHARS
else:
num_value_padding_chars = num_padding_chars
if isinstance(template, Path):
template = str(template)
if not template:
template = get_template("default")
if style not in ["gitlab-scoped"]:
style = "default"
if not default_color:
default_color = config.DEFAULT_COLOR
if not text_color:
text_color = config.DEFAULT_TEXT_COLOR
self.label = label
self.value = value
if self.label is None:
self.label = ""
if self.value is None:
self.value = ""
if len(str(self.label)) == 0 and len(str(self.value)) == 0:
raise ValueError("Either a label or a value must be provided for a badge.")
self.value_is_version = semver
self.value_format = value_format
if value_format:
value_text = str(value_format % self.value_type(value))
else:
value_text = str(self.value_type(value))
self.value_prefix = value_prefix
self.value_suffix = value_suffix
# Combine prefix, value and suffix into a single value_text string
if value_prefix:
self.value_text = value_prefix
else:
self.value_text = ""
self.value_text += value_text
if value_suffix:
self.value_text += value_suffix
if font_name not in config.FONT_WIDTHS:
raise ValueError(
'Font name "%s" not found. '
"Available fonts: %s"
% (font_name, ", ".join(config.FONT_WIDTHS.keys()))
)
self.font_name = font_name
self.font_size = font_size
self.num_label_padding_chars = num_label_padding_chars
self.num_value_padding_chars = num_value_padding_chars
self.template = template
self.style = style
self.thresholds = thresholds
self.default_color = default_color
# text_color can be passed as a single value or a pair of comma delimited values
self.text_color = text_color
text_colors = text_color.split(",")
self.label_text_color = text_colors[0]
self.value_text_color = text_colors[0]
if len(text_colors) > 1:
self.value_text_color = text_colors[1]
self.use_max_when_value_exceeds = use_max_when_value_exceeds
self.mask_str = self.__class__._get_next_mask_str()
def __repr__(self) -> str:
"""Return a representation of the Badge object instance.
The output of the __repr__ function could be used to recreate the current object.
Examples:
>>> badge = Badge('example', '123.456')
>>> repr(badge)
"Badge('example', '123.456')"
>>> badge = Badge('example', '123.456', value_suffix='TB')
>>> repr(badge)
"Badge('example', '123.456', value_suffix='TB')"
>>> badge = Badge('example', '123.456', text_color='#111111', value_suffix='TB')
>>> repr(badge)
"Badge('example', '123.456', value_suffix='TB', text_color='#111111')"
>>> badge = Badge('example', '123', num_padding_chars=5)
>>> repr(badge)
"Badge('example', '123', num_padding_chars=5)"
>>> badge = Badge('example', '123', num_label_padding_chars=5)
>>> repr(badge)
"Badge('example', '123', num_label_padding_chars=5)"
>>> badge = Badge('example', '123', num_label_padding_chars=5, num_value_padding_chars=6,
... template='template.svg', value_prefix='$', thresholds={10: 'green', 30: 'red'},
... default_color='red', use_max_when_value_exceeds=False, value_format="%s m/s")
>>> repr(badge)
"Badge('example', '123', num_label_padding_chars=5, num_value_padding_chars=6, template='template.svg', value_prefix='$', thresholds={10: 'green', 30: 'red'}, default_color='red', use_max_when_value_exceeds=False, value_format='%s m/s')"
"""
optional_args = ""
if self.font_name != config.DEFAULT_FONT:
optional_args += ", font_name=%s" % repr(self.font_name)
if self.font_size != config.DEFAULT_FONT_SIZE:
optional_args += ", font_size=%s" % repr(self.font_size)
if self.num_label_padding_chars == self.num_value_padding_chars:
if self.num_label_padding_chars != config.NUM_PADDING_CHARS:
optional_args += ", num_padding_chars=%s" % repr(
self.num_label_padding_chars
)
else:
if self.num_label_padding_chars != config.NUM_PADDING_CHARS:
optional_args += ", num_label_padding_chars=%s" % repr(
self.num_label_padding_chars
)
if self.num_value_padding_chars != config.NUM_PADDING_CHARS:
optional_args += ", num_value_padding_chars=%s" % repr(
self.num_value_padding_chars
)
if self.template != get_template("default"):
optional_args += ", template=%s" % repr(self.template)
if self.style != "default":
optional_args += ", style=%s" % repr(self.style)
if self.value_prefix != "":
optional_args += ", value_prefix=%s" % repr(self.value_prefix)
if self.value_suffix != "":
optional_args += ", value_suffix=%s" % repr(self.value_suffix)
if self.thresholds:
optional_args += ", thresholds=%s" % repr(self.thresholds)
if self.default_color != config.DEFAULT_COLOR:
optional_args += ", default_color=%s" % repr(self.default_color)
if not self.use_max_when_value_exceeds:
optional_args += ", use_max_when_value_exceeds=%s" % repr(
self.use_max_when_value_exceeds
)
if self.value_format:
optional_args += ", value_format=%s" % repr(self.value_format)
if self.text_color != config.DEFAULT_TEXT_COLOR:
optional_args += ", text_color=%s" % repr(self.text_color)
return "%s(%s, %s%s)" % (
self.__class__.__name__,
repr(self.label),
repr(self.value),
optional_args,
)
def _repr_svg_(self) -> str:
"""Return SVG representation when used inside Jupyter notebook cells.
This will render the SVG immediately inside a notebook cell when creating
a Badge instance without assigning it to an identifier.
"""
return self.badge_svg_text
@classmethod
def _get_next_mask_str(cls) -> str:
"""Return a new mask ID from a singleton sequence maintained on the class.
Returns: str
"""
if not hasattr(cls, "mask_id"):
cls.mask_id = 0
cls.mask_id += 1
return config.MASK_ID_PREFIX + str(cls.mask_id)
def _get_svg_template(self) -> str:
"""Return the correct SVG template to render, based on the style and template
that have been set
Returns: str
"""
if self.style == "gitlab-scoped":
return get_template("gitlab_scoped")
# Identify whether template is a file or the actual template text
if len(self.template.split("\n")) == 1:
try:
return get_template(self.template)
except UnknownBadgeTemplate:
pass
with open(self.template, mode="r") as file_handle:
return file_handle.read()
else:
return self.template
@property
def semver_version(self) -> Version:
"""The semantic version represented by the value string.
Returns: Version
"""
return Version(self.value)
@property
def semver_thresholds(self) -> Optional[OrderedDict]:
"""Thresholds as a dict using Version as keys."""
# Version is not a hashable type, so can't be used to create an
# ordered dict directly. First we need to create an ordered list of keys
if not self.thresholds:
return None
ordered_keys = sorted(self.thresholds.keys(), key=Version)
return OrderedDict((key, self.thresholds[key]) for key in ordered_keys)
@property
def float_thresholds(self) -> Optional[Dict[float, str]]:
"""Thresholds as a dict using floats as keys."""
if not self.thresholds:
return None
return {float(k): v for k, v in self.thresholds.items()}
@property
def value_is_float(self) -> bool:
"""Identify whether the value text is a float.
Returns: bool
"""
# If the value is an int then it should not be considered a float.
# We need to check this first before we check whether it is a float because the
# float check also returns True for an int string.
if self.value_is_int:
return False
try:
_ = float(self.value)
except (ValueError, TypeError):
return False
else:
return True
@property
def value_is_int(self) -> bool:
"""Identify whether the value text is an int.
Returns: bool
"""
try:
a = float(self.value)
b = int(self.value)
except (ValueError, TypeError):
return False
else:
return a == b
@property
def value_type(self) -> Type:
"""The Python type associated with the value.
Returns: type
"""
if self.value_is_version:
return Version
if self.value_is_float:
return float
elif self.value_is_int:
return int
else:
return str
@property
def label_width(self) -> int:
"""The SVG width of the label text.
┌───────────────────────────┬────────────────────────────────┐
│ │ │
│ Label text │ Value │
│ │ │
└───────────────────────────┴────────────────────────────────┘
◀────────▶
label_width
Returns: int
"""
if len(str(self.label)) == 0:
return 0
return int(
self.get_text_width(str(self.label))
+ (2.0 * self.num_label_padding_chars * self.font_width)
)
@property
def value_width(self) -> int:
"""The SVG width of the value text.
┌───────────────────────────┬────────────────────────────────┐
│ │ │
│ Label text │ Value text │
│ │ │
└───────────────────────────┴────────────────────────────────┘
◀────────▶
value_width
Returns: int
"""
if len(str(self.value_text)) == 0:
return 0
return int(
self.get_text_width(str(self.value_text))
+ (2.0 * self.num_value_padding_chars * self.font_width)
)
@property
def value_box_width(self) -> int:
"""The SVG width of the value text box.
┌───────────────────────────┬────────────────────────────────┐
│ │ │
│ Label text │ Value text │
│ │ │
└───────────────────────────┴────────────────────────────────┘
◀────────────────────────────────▶
value_box_width
Returns: int
"""
return self.value_width - 9
@property
def font_width(self) -> int:
"""Return the width multiplier for a font.
Returns:
int: Maximum pixel width of badges selected font.
Example:
>>> Badge(label='x', value='1').font_width
10
"""
return config.FONT_WIDTHS[self.font_name][self.font_size]
@property
def color_split_position(self) -> int:
"""The SVG x position where the color split should occur.
Split
┌──┘
┌───────────────────────────┬────────────────────────────────┐
│ │ │
│ Label text │ Value text │
│ │ │
└───────────────────────────┴────────────────────────────────┘
◀───────────────────────────▶
color_split_pos
Returns: int
"""
return self.badge_width - self.value_width
@property
def label_anchor(self) -> float:
"""The SVG x position of the middle anchor for the label text.
Middle of
label
┌──┘
┌───────────────────────────┬────────────────────────────────┐
│ │ │
│ Label text │ Value text │
│ │ │
└───────────────────────────┴────────────────────────────────┘
◀───────────▶
label_anchor
Returns: float
"""
return self.color_split_position / 2
@property
def value_anchor(self) -> float:
"""The SVG x position of the middle anchor for the value text.
Middle of
value
┌──┘
┌───────────────────────────┬────────────────────────────────┐
│ │ │
│ Label text │ Value text │
│ │ │
└───────────────────────────┴────────────────────────────────┘
◀──────────────────────────────────────────▶
value_anchor
Returns: float
"""
return self.color_split_position + (
(self.badge_width - self.color_split_position) / 2
)
@property
def label_anchor_shadow(self) -> float:
"""The SVG x position of the label shadow anchor.
The shadow for the label will appear behind the label.
┌ Text ──────────────────┐
│ │
│ ├────┐
│ │ │
└────┬───────────────────┘ │
│ │
└───────────── Shadow ───┘
The label_anchor_shadow is the distance from left to center of shadow:
┌─────────────────────────────┬─────────────────────────────────┐
│ ┌────────────┐ │ │
│ │ ├─┐ │ Value text │
│ └─┬──────────┘ │ │ │
│ └────────────┘ │ │
└─────────────────────────────┴─────────────────────────────────┘
◀─────────────▶
label_anchor_shadow
Returns: float
"""
return self.label_anchor + 1
@property
def value_anchor_shadow(self) -> float:
"""The SVG x position of the value shadow anchor.
┌─────────────────────────────┬─────────────────────────────────┐
│ │ ┌────────────┐ │
│ Label text │ │ ├─┐ │
│ │ └─┬──────────┘ │ │
│ │ └────────────┘ │
└─────────────────────────────┴─────────────────────────────────┘
◀───────────────────────────────────────────────▶
value_anchor_shadow
Returns: float
"""
return self.value_anchor + 1
@property
def badge_width(self) -> int:
"""The total width of badge.
┌───────────────────────────┬────────────────────────────────┐
│ │ │
│ Label text │ Value text │
│ │ │
└───────────────────────────┴────────────────────────────────┘
◀───────────────────────────────────────────────────────────▶
badge_width
Returns: int
Examples:
>>> badge = Badge('pylint', '5')
>>> badge.badge_width
61
"""
return self.label_width + self.value_width
@property
def arc_start(self) -> int:
"""The position where the arc on the arc should start.
Returns: int
Examples:
>>> badge = Badge('pylint', '5')
>>> badge.arc_start
51
"""
return self.badge_width - 10
@property
def badge_svg_text(self) -> str:
"""The badge SVG text.
Returns: str
"""
badge_text = self._get_svg_template()
return (
badge_text.replace("{{ badge width }}", str(self.badge_width))
.replace("{{ font name }}", self.font_name)
.replace("{{ font size }}", str(self.font_size))
.replace("{{ label }}", self.label)
.replace("{{ value }}", self.value_text)
.replace("{{ label anchor }}", str(self.label_anchor))
.replace("{{ label anchor shadow }}", str(self.label_anchor_shadow))
.replace("{{ value anchor }}", str(self.value_anchor))
.replace("{{ value anchor shadow }}", str(self.value_anchor_shadow))
.replace("{{ color }}", self.badge_color_code)
.replace("{{ label text color }}", self.label_text_color)
.replace("{{ value text color }}", self.value_text_color)
.replace("{{ color split x }}", str(self.color_split_position))
.replace("{{ value width }}", str(self.value_width))
.replace("{{ mask id }}", self.mask_str)
.replace("{{ value box width }}", str(self.value_box_width))
.replace("{{ arc start }}", str(self.arc_start))
)
def __str__(self) -> str:
"""Return string representation of badge.
This will return the badge SVG text.
Returns: str
Examples:
>>> print(Badge('example', '123')) # doctest: +ELLIPSIS
<?xml version="1.0" encoding="UTF-8"?>
...
"""
return self.badge_svg_text
def get_text_width(self, text) -> int:
"""Return the width of text.
Args:
text(str): Text to get the pixel width of.
Returns:
int: Pixel width of the given text based on the badges selected font.
This implementation assumes a fixed font of:
font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"
>>> badge = Badge('x', 1, font_name='DejaVu Sans,Verdana,Geneva,sans-serif', font_size=11)
>>> badge.get_text_width('pylint')
34
"""
return _get_approx_string_width(text, self.font_width)
@property
def badge_color(self) -> str:
"""Badge color based on the configured thresholds.
Returns: str"""
# If no thresholds were passed then return the default color
if not self.thresholds:
return self.default_color
if self.value_type == str:
if self.value in self.thresholds:
return self.thresholds[self.value]
else:
return self.default_color
thresholds: Optional[Union[Dict[float, str], OrderedDict[float, str]]]
# Set value and thresholds based on the value type. This will result in either
# value and thresholds as floats or value and thresholds as semantic versions.
if self.value_type == Version:
value = self.semver_version
thresholds = self.semver_thresholds
else:
value = float(self.value)
thresholds = self.float_thresholds
color = None
if thresholds:
# Convert the threshold dictionary into a sorted list of lists
threshold_list = [[self.value_type(i[0]), i[1]] for i in thresholds.items()]
threshold_list.sort(key=lambda x: x[0])
for threshold, color in threshold_list:
if value < threshold:
return color
# If we drop out the top of the range then return the last max color
if color and self.use_max_when_value_exceeds:
return color
else:
return self.default_color
@property
def badge_color_code(self) -> str:
"""Return the color code for the badge.
Returns: str
Raises: ValueError when an invalid badge color is set.
"""
color = self.badge_color
if isinstance(color, Color):
return color.value
if color[0] == "#":
return color
color = color.upper()
prefixes = ["BRIGHT", "YELLOW", "LIGHT"]
try:
return Color[color.upper()].value
except KeyError:
pass
# For backward compatibility with old color names (that were lowercase and didn't
# contain underscores) we will try to get the same color.
for prefix in prefixes:
if color.startswith(prefix) and color != prefix and "_" not in color:
try:
return Color[color.replace(prefix, prefix + "_")].value
except KeyError:
pass
raise ValueError(
'Invalid color code "%s". ' "Valid color codes are: %s",
(color, ", ".join(list(Color.__members__.keys()))),
)
def write_badge(self, file_path: Union[str, Path], overwrite=False) -> None:
"""Write badge to file."""
if isinstance(file_path, str):
if file_path.endswith("/"):
raise ValueError("File location may not be a directory.")
file: Path = Path(file_path)
else:
file = file_path
# Validate path (part 1)
if file.is_dir():
raise ValueError("File location may not be a directory.")
# Ensure we're using a .svg extension
file = file.with_suffix(".svg")
# Validate path (part 2)
if not overwrite and file.exists():
raise RuntimeError('File "{}" already exists.'.format(file))
with open(file, mode="w") as file_handle:
file_handle.write(self.badge_svg_text)