Skip to content

Batch generator

BadgeGenerator(template: BadgeTemplate = BadgeTemplate.DEFAULT, log_level: Union[LogLevel, str] = LogLevel.WARNING, style: Optional[BadgeStyle] = None)

Generate customizable SVG badges.

PARAMETER DESCRIPTION

template

The name of the SVG template file.

TYPE: BadgeTemplate DEFAULT: DEFAULT

log_level

The logging level. Options are 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'.

TYPE: Union[LogLevel, str] DEFAULT: WARNING

style

Visual style preset. Defaults to :attr:BadgeStyle.FLAT.

TYPE: Optional[BadgeStyle] DEFAULT: None

METHOD DESCRIPTION
is_valid_hex_color

Validate if a string is a valid hex color.

validate_color

Validate the color input.

validate_frame

Validate the frame input.

validate_inputs

Validate parameters shared by the different badge templates.

local_path

Get the absolute path for a given relative path.

get_base64_content

Get the base64 encoded content of a binary file.

generate_badge

Generates a badge based on the provided parameters and saves it as an SVG file.

Source code in src/badgeshield/badge_generator.py
Python
def __init__(
    self,
    template: BadgeTemplate = BadgeTemplate.DEFAULT,
    log_level: Union[LogLevel, str] = LogLevel.WARNING,
    style: Optional["BadgeStyle"] = None,
) -> None:
    """Initializes the BadgeGenerator with a specified template and log level.

    Parameters
    ----------
    template:
        The name of the SVG template file.
    log_level:
        The logging level. Options are 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'.
    style:
        Visual style preset. Defaults to :attr:`BadgeStyle.FLAT`.
    """

    if isinstance(log_level, str):
        try:
            log_level = LogLevel[log_level.upper()]
        except KeyError:
            raise ValueError(
                f"Invalid log_level '{log_level}'. "
                f"Choose from: {', '.join(lv.name for lv in LogLevel)}"
            )
    self.template_name = str(template)
    self.template_enum = template  # kept for registry dispatch
    self._last_render_context: Optional[dict] = None
    self.style = style if style is not None else BadgeStyle.FLAT
    self._setup_jinja2_env()
    self.logger = get_logger(name="badgeshield", log_level=log_level)

is_valid_hex_color(color: str) -> bool staticmethod

Validate if a string is a valid hex color.

Source code in src/badgeshield/badge_generator.py
Python
@staticmethod
def is_valid_hex_color(color: str) -> bool:
    """Validate if a string is a valid hex color."""
    return bool(re.match(r"^#([A-Fa-f0-9]{6})$", color))

validate_color(color: Union[BadgeColor, str], color_name: str) -> str staticmethod

Validate the color input.

Source code in src/badgeshield/badge_generator.py
Python
@staticmethod
def validate_color(color: Union[BadgeColor, str], color_name: str) -> str:
    """Validate the color input."""
    if isinstance(color, BadgeColor):
        return color.value
    if isinstance(color, str):
        if BadgeGenerator.is_valid_hex_color(color):
            return color
        try:
            enum_color = BadgeColor[color.upper()]
        except KeyError as exc:
            raise ValueError(
                f"Invalid {color_name}: {color}. Must be a hex color code (e.g., #00FF00) or a member of BadgeColor."
            ) from exc
        return enum_color.value
    raise TypeError(
        f"{color_name} must be a hex string or an instance of BadgeColor, not {type(color).__name__}."
    )

validate_frame(frame: Optional[Union[FrameType, str]]) -> str staticmethod

Validate the frame input.

Source code in src/badgeshield/badge_generator.py
Python
@staticmethod
def validate_frame(frame: Optional[Union[FrameType, str]]) -> str:
    """Validate the frame input."""
    if frame is None:
        raise ValueError(
            "The 'frame' parameter is required when using the CIRCLE_FRAME template."
        )
    if isinstance(frame, FrameType):
        return frame.value
    if isinstance(frame, str):
        try:
            # Convert the frame string to a FrameType enum instance
            enum_frame = FrameType[frame.upper()]
        except KeyError as exc:
            raise ValueError(
                f"Invalid frame type '{frame}'. Must be one of {list(FrameType.__members__.keys())}."
            ) from exc
        return enum_frame.value
    raise TypeError(
        f"frame must be an instance of FrameType or str of valid FrameType name, not {type(frame).__name__}."
    )

validate_inputs(left_text: str, left_color: Union[BadgeColor, str], output_path: Optional[str], badge_name: str, right_text: Optional[str] = None, right_color: Optional[Union[BadgeColor, str]] = None, logo: Optional[str] = None, frame: Optional[Union[FrameType, str]] = None) -> Tuple[str, str, str, Optional[str]]

Validate parameters shared by the different badge templates.

RETURNS DESCRIPTION
tuple

Containing the normalized left color, right color, output directory, and frame asset path (when applicable).

TYPE: Tuple[str, str, str, Optional[str]]

RAISES DESCRIPTION
ValueError:

If any argument is missing or malformed (e.g., empty text, invalid output directory, missing logo file, incorrect badge name suffix).

TypeError:

When a value has the wrong type for its expected enum/string input. # type: ignore[arg-type]

Source code in src/badgeshield/badge_generator.py
Python
def validate_inputs(
    self,
    left_text: str,
    left_color: Union[BadgeColor, str],
    output_path: Optional[str],
    badge_name: str,
    right_text: Optional[str] = None,
    right_color: Optional[Union[BadgeColor, str]] = None,
    logo: Optional[str] = None,
    frame: Optional[Union[FrameType, str]] = None,
) -> Tuple[str, str, str, Optional[str]]:
    """Validate parameters shared by the different badge templates.

    Returns
    -------
    tuple:
        Containing the normalized left color, right color, output directory, and frame asset path (when applicable).

    Raises
    ------
    ValueError:
        If any argument is missing or malformed (e.g., empty text, invalid output directory, missing logo file, incorrect badge name suffix).
    TypeError:
        When a value has the wrong type for its expected enum/string input.  # type: ignore[arg-type]
    """
    if not left_text:
        raise ValueError("left_text cannot be empty.")

    # Validate the frame parameter if the CIRCLE_FRAME template is used
    frame_value: Optional[str]
    if self.template_name == BadgeTemplate.CIRCLE_FRAME.value:
        frame_value = self.validate_frame(frame)  # type: ignore[arg-type]
    elif isinstance(frame, FrameType):
        frame_value = frame.value
    else:
        frame_value = frame

    # Validate color codes
    left_color_value = self.validate_color(left_color, "left_color")
    right_color_value = (
        self.validate_color(right_color, "right_color")
        if right_color
        else left_color_value
    )

    # Validate logo file existence when a logo path is provided
    if logo:
        logo_path = Path(logo)
        if not logo_path.is_absolute():
            logo_path = Path(self.local_path(logo))
        if not logo_path.is_file():
            raise ValueError(f"Logo file {logo} does not exist.")

    # Use current directory if output_path is not provided
    if output_path is not None:
        if output_path == "":
            raise ValueError("output_path cannot be an empty string.")
        output_path = str(Path(output_path).resolve())
    else:
        output_path = str(Path.cwd())

    # Ensure output path is a directory
    if not Path(output_path).is_dir():
        raise ValueError(f"Output path {output_path} is not a valid directory.")

    # Ensure badge_name is valid
    if not badge_name.endswith(".svg"):
        raise ValueError(
            f"badge_name {badge_name} is not valid, must end with '.svg' (e.g., 'badge.svg')."
        )

    # Prevent path traversal and absolute paths
    badge_path = Path(badge_name)
    if any(part in ("..", ".") for part in badge_path.parts[:-1]):
        raise ValueError(
            f"badge_name '{badge_name}' must not contain directory traversal."
        )
    # Use both Posix and Windows pure-path checks so the guard works
    # regardless of which OS is running (e.g. "/tmp/x.svg" is absolute on
    # Linux but not on Windows; "C:/x.svg" is the reverse).
    if (
        PurePosixPath(badge_name).is_absolute()
        or PureWindowsPath(badge_name).is_absolute()
    ):
        raise ValueError(
            f"badge_name '{badge_name}' must be a plain filename, not an absolute path."
        )

    return left_color_value, right_color_value, output_path, frame_value

local_path(relative_path: str) -> str

Get the absolute path for a given relative path.

PARAMETER DESCRIPTION

relative_path

The relative path to resolve.

TYPE: str

RETURNS DESCRIPTION
str

The absolute resolved path.

TYPE: str

Source code in src/badgeshield/badge_generator.py
Python
def local_path(self, relative_path: str) -> str:
    """Get the absolute path for a given relative path.

    Parameters
    ----------
    relative_path:
        The relative path to resolve.

    Returns
    -------
    str:
        The absolute resolved path.
    """
    path_obj = Path(relative_path)
    if path_obj.is_absolute():
        return str(path_obj)

    base_path = Path(__file__).resolve().parent / path_obj
    if base_path.exists():
        return str(base_path)

    return str((Path.cwd() / path_obj).resolve())

get_base64_content(bin_file: str) -> str

Get the base64 encoded content of a binary file.

Source code in src/badgeshield/badge_generator.py
Python
def get_base64_content(self, bin_file: str) -> str:
    """Get the base64 encoded content of a binary file."""
    bin_path = Path(bin_file)
    if not bin_path.is_absolute():
        bin_path = Path(self.local_path(bin_file))
    try:
        file_size = bin_path.stat().st_size
        if file_size > self._MAX_LOGO_BYTES:
            raise ValueError(
                f"Logo file too large ({file_size} bytes); limit is {self._MAX_LOGO_BYTES} bytes: {bin_path}"
            )
        with bin_path.open(
            "rb", buffering=16 * 1024
        ) as f:  # Use a larger buffer for efficient file I/O
            data = f.read()
        return base64.b64encode(data).decode()
    except FileNotFoundError:
        self.logger.error(f"File not found: {bin_path}")
        raise
    except Exception as e:
        self.logger.error(f"Error reading file {bin_path}: {e}")
        raise

generate_badge(left_text: str, left_color: Union[BadgeColor, str], badge_name: str, output_path: Optional[str] = None, right_text: Optional[str] = None, right_color: Optional[Union[BadgeColor, str]] = None, logo: Optional[str] = None, frame: Optional[Union[FrameType, str]] = None, left_link: Optional[str] = None, right_link: Optional[str] = None, id_suffix: str = '', left_title: Optional[str] = None, right_title: Optional[str] = None, logo_tint: Optional[Union[str, BadgeColor]] = None) -> str

Generates a badge based on the provided parameters and saves it as an SVG file.

PARAMETER DESCRIPTION

left_text

Text for the left side of the badge.

TYPE: str

left_color

Background color for the left side of the badge.

TYPE: Union[BadgeColor, str]

badge_name

The name of the badge file to be generated.

TYPE: str

output_path

Output path for the generated badge SVG file.

TYPE: Optional[str] DEFAULT: None

right_text

Text for the right side of the badge. Defaults to None.

TYPE: Optional[str] DEFAULT: None

right_color

Background color for the right side of the badge. Defaults to None.

TYPE: Optional[Union[BadgeColor, str]] DEFAULT: None

logo

URL or path to a logo image. Defaults to None.

TYPE: Optional[str] DEFAULT: None

frame

Frame template to use, either as a FrameType or its name/path.

TYPE: Optional[Union[FrameType, str]] DEFAULT: None

left_link

Link for the left side of the badge. Defaults to None.

TYPE: Optional[str] DEFAULT: None

right_link

Link for the right side of the badge. Defaults to None.

TYPE: Optional[str] DEFAULT: None

id_suffix

Suffix to add to the ID of the badge elements. Defaults to ''.

TYPE: str DEFAULT: ''

left_title

Title text for the left side of the badge. Defaults to None.

TYPE: Optional[str] DEFAULT: None

right_title

Title text for the right side of the badge. Defaults to None.

TYPE: Optional[str] DEFAULT: None

logo_tint

Optional color applied to monochrome the logo.

TYPE: Optional[Union[str, BadgeColor]] DEFAULT: None

RAISES DESCRIPTION
ValueError:

If the output_path is empty or invalid.

TypeError:

If frame is not an instance of FrameType.

RETURNS DESCRIPTION
str

The absolute path of the written SVG file, equivalent to str(Path(output_path) / badge_name).

TYPE: str

Source code in src/badgeshield/badge_generator.py
Python
def generate_badge(
    self,
    left_text: str,
    left_color: Union[BadgeColor, str],
    badge_name: str,
    output_path: Optional[str] = None,
    right_text: Optional[str] = None,
    right_color: Optional[Union[BadgeColor, str]] = None,
    logo: Optional[str] = None,
    frame: Optional[Union[FrameType, str]] = None,
    left_link: Optional[str] = None,
    right_link: Optional[str] = None,
    id_suffix: str = "",
    left_title: Optional[str] = None,
    right_title: Optional[str] = None,
    logo_tint: Optional[Union[str, BadgeColor]] = None,
) -> str:
    """Generates a badge based on the provided parameters and saves it as an SVG file.

    Parameters
    ----------
    left_text:
        Text for the left side of the badge.
    left_color:
        Background color for the left side of the badge.
    badge_name:
        The name of the badge file to be generated.
    output_path:
        Output path for the generated badge SVG file.
    right_text:
        Text for the right side of the badge. Defaults to None.
    right_color:
        Background color for the right side of the badge. Defaults to None.
    logo:
        URL or path to a logo image. Defaults to None.
    frame:
        Frame template to use, either as a FrameType or its name/path.
    left_link:
        Link for the left side of the badge. Defaults to None.
    right_link:
        Link for the right side of the badge. Defaults to None.
    id_suffix:
        Suffix to add to the ID of the badge elements. Defaults to ''.
    left_title:
        Title text for the left side of the badge. Defaults to None.
    right_title:
        Title text for the right side of the badge. Defaults to None.
    logo_tint:
        Optional color applied to monochrome the logo.

    Raises
    ------
    ValueError:
        If the output_path is empty or invalid.
    TypeError:
        If frame is not an instance of FrameType.

    Returns
    -------
    str:
        The absolute path of the written SVG file, equivalent to
        ``str(Path(output_path) / badge_name)``.
    """
    left_color_value, right_color_value, output_path, frame = self.validate_inputs(
        left_text,
        left_color,
        output_path,
        badge_name,
        right_text,
        right_color,
        logo,
        frame,
    )
    full_path = str(Path(output_path) / badge_name)

    try:
        badge_content = self._render_badge_content(
            left_text,
            left_color_value,
            right_text,
            right_color_value,
            logo,
            frame,
            left_link,
            right_link,
            id_suffix,
            left_title,
            right_title,
            logo_tint,
            self.style,
        )

        with open(full_path, "w", encoding="utf-8") as file:
            file.write(badge_content)
        self.logger.info(f"Badge generated and saved to {full_path}")
        return full_path
    except Exception as e:
        self.logger.error(f"An error occurred while generating the badge: {e}")
        raise

BadgeBatchGenerator(max_workers: int = 5, log_level: Union[LogLevel, str] = LogLevel.INFO)

Generate many badges concurrently using a thread pool.

METHOD DESCRIPTION
generate_batch

Generate multiple badges concurrently.

Source code in src/badgeshield/badge_generator.py
Python
def __init__(
    self, max_workers: int = 5, log_level: Union[LogLevel, str] = LogLevel.INFO
):
    """Initializes the batch generator with a specified number of worker threads."""
    self.max_workers = max_workers
    self.log_level = log_level
    self.logger = get_logger(name="badgeshield.batch", log_level=log_level)
    self._failures: List[Tuple[str, str]] = []

generate_batch(badges: List[Dict], progress_callback: Optional[Callable[[str], None]] = None) -> None

Generate multiple badges concurrently.

PARAMETER DESCRIPTION

badges

TYPE: List[Dict]

progress_callback

TYPE: Optional[Callable[[str], None]] DEFAULT: None

RAISES DESCRIPTION
RuntimeError:

Aggregated failures when one or more badges cannot be generated.

Source code in src/badgeshield/badge_generator.py
Python
def generate_batch(
    self,
    badges: List[Dict],
    progress_callback: Optional[Callable[[str], None]] = None,
) -> None:
    """Generate multiple badges concurrently.

    Parameters
    ----------
    badges: A list of dictionaries containing keyword arguments accepted by :meth:`BadgeGenerator.generate_badge`.
    progress_callback: Optional callable invoked with the badge_name after each badge completes (success or failure).

    Raises
    ------
    RuntimeError:
        Aggregated failures when one or more badges cannot be generated.
    """
    self._failures = []  # reset on each call
    with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
        future_to_badge = {
            executor.submit(self._generate_single_badge, **badge): badge
            for badge in badges
        }
        errors: List[Tuple[Dict, Exception]] = []

        for future in as_completed(future_to_badge):
            badge = future_to_badge[future]
            badge_name = badge.get("badge_name", "unknown")
            try:
                future.result()
            except Exception as exc:
                self.logger.error(
                    "Failed to generate badge",
                    extra={"badge": badge_name, "error": str(exc)},
                )
                errors.append((badge, exc))
                self._failures.append((badge_name, str(exc)))
            else:
                self.logger.info(
                    "Successfully generated badge",
                    extra={"badge": badge_name},
                )
            finally:
                if progress_callback is not None:
                    progress_callback(badge_name)

        if errors:
            failure_summaries = ", ".join(
                f"{name}: {err}" for name, err in self._failures
            )
            raise RuntimeError(
                f"Failed to generate {len(errors)} badge(s): {failure_summaries}"
            )