Skip to content

Batch generator cli

generate_badge_cli

FUNCTION DESCRIPTION
single

Generate a single SVG badge.

batch

Batch-generate SVG badges from a JSON config file.

coverage

Generate a coverage badge from a coverage.xml report.

audit

Audit an SVG file for external resource references.

presets_list

List all available badge presets.

preset_cmd

Generate a badge from a named preset (see 'badgeshield presets' for the full list).

single(left_text: str = typer.Option(..., '--left-text', help='Text for the left section'), left_color: str = typer.Option(..., '--left-color', help='Hex (#RRGGBB) or BadgeColor name e.g. GREEN'), badge_name: str = typer.Option(..., '--badge-name', help='Output filename, must end with .svg'), template: str = typer.Option('DEFAULT', help='DEFAULT | CIRCLE | CIRCLE_FRAME | PILL | BANNER'), output_path: Optional[str] = typer.Option(None, '--output-path', help='Output directory; defaults to current directory'), right_text: Optional[str] = typer.Option(None, help='Text for the right section (omit for label-only badge)'), right_color: Optional[str] = typer.Option(None, help='Hex (#RRGGBB) or BadgeColor name for right section'), logo: Optional[str] = typer.Option(None, help='Path to a logo image (embedded as base64)'), logo_tint: Optional[str] = typer.Option(None, help='Hex or BadgeColor name to tint the logo'), frame: Optional[str] = typer.Option(None, help='Frame type (FRAME1–FRAME11) β€” required for CIRCLE_FRAME template'), left_link: Optional[str] = typer.Option(None, help='Hyperlink URL for the left section'), right_link: Optional[str] = typer.Option(None, help='Hyperlink URL for the right section'), id_suffix: str = typer.Option('', help='Suffix appended to SVG element IDs (useful when embedding multiple badges)'), left_title: Optional[str] = typer.Option(None, help='SVG title tooltip for the left section'), right_title: Optional[str] = typer.Option(None, help='SVG title tooltip for the right section'), log_level: str = typer.Option('INFO', help='DEBUG | INFO | WARNING | ERROR | CRITICAL'), style: str = typer.Option('flat', help='FLAT | ROUNDED | GRADIENT | SHADOWED'), format: Optional[str] = typer.Option(None, '--format', help='Print an embed snippet to stdout: markdown | rst | html')) -> None

Generate a single SVG badge.

Source code in src/badgeshield/generate_badge_cli.py
Python
@app.command()
def single(
    left_text: str = typer.Option(..., "--left-text", help="Text for the left section"),
    left_color: str = typer.Option(
        ..., "--left-color", help="Hex (#RRGGBB) or BadgeColor name e.g. GREEN"
    ),
    badge_name: str = typer.Option(
        ..., "--badge-name", help="Output filename, must end with .svg"
    ),
    template: str = typer.Option(
        "DEFAULT", help="DEFAULT | CIRCLE | CIRCLE_FRAME | PILL | BANNER"
    ),
    output_path: Optional[str] = typer.Option(
        None, "--output-path", help="Output directory; defaults to current directory"
    ),
    right_text: Optional[str] = typer.Option(
        None, help="Text for the right section (omit for label-only badge)"
    ),
    right_color: Optional[str] = typer.Option(
        None, help="Hex (#RRGGBB) or BadgeColor name for right section"
    ),
    logo: Optional[str] = typer.Option(
        None, help="Path to a logo image (embedded as base64)"
    ),
    logo_tint: Optional[str] = typer.Option(
        None, help="Hex or BadgeColor name to tint the logo"
    ),
    frame: Optional[str] = typer.Option(
        None, help="Frame type (FRAME1–FRAME11) β€” required for CIRCLE_FRAME template"
    ),
    left_link: Optional[str] = typer.Option(
        None, help="Hyperlink URL for the left section"
    ),
    right_link: Optional[str] = typer.Option(
        None, help="Hyperlink URL for the right section"
    ),
    id_suffix: str = typer.Option(
        "",
        help="Suffix appended to SVG element IDs (useful when embedding multiple badges)",
    ),
    left_title: Optional[str] = typer.Option(
        None, help="SVG title tooltip for the left section"
    ),
    right_title: Optional[str] = typer.Option(
        None, help="SVG title tooltip for the right section"
    ),
    log_level: str = typer.Option(
        "INFO", help="DEBUG | INFO | WARNING | ERROR | CRITICAL"
    ),
    style: str = typer.Option("flat", help="FLAT | ROUNDED | GRADIENT | SHADOWED"),
    format: Optional[str] = typer.Option(
        None, "--format", help="Print an embed snippet to stdout: markdown | rst | html"
    ),
) -> None:
    """Generate a single SVG badge."""
    try:
        log_level_enum = LogLevel[log_level.upper()]
    except KeyError:
        _error(
            f"Invalid log_level '{log_level}'. "
            f"Choose from: {', '.join(lv.name for lv in LogLevel)}"
        )
        raise typer.Exit(1)

    try:
        style_enum = BadgeStyle[style.upper()]
    except KeyError:
        _error(
            f"Invalid style '{style}'. "
            f"Choose from: {', '.join(s.name for s in BadgeStyle)}"
        )
        raise typer.Exit(1)

    try:
        template_enum = BadgeTemplate[template.upper()]
    except KeyError:
        _error(
            f"Invalid template '{template}'. "
            f"Choose from: {', '.join(tmpl.name for tmpl in BadgeTemplate)}"
        )
        raise typer.Exit(1)

    try:
        frame_enum = FrameType[frame.upper()] if frame else None
    except KeyError:
        _error(
            f"Invalid frame '{frame}'. "
            f"Choose from: {', '.join(ft.name for ft in FrameType)}"
        )
        raise typer.Exit(1)

    try:
        generator = BadgeGenerator(
            template=template_enum, log_level=log_level_enum, style=style_enum
        )
        generator.generate_badge(
            left_text=left_text,
            left_color=left_color,
            badge_name=badge_name,
            output_path=output_path,
            right_text=right_text,
            right_color=right_color,
            logo=logo,
            frame=frame_enum,
            left_link=left_link,
            right_link=right_link,
            id_suffix=id_suffix,
            left_title=left_title,
            right_title=right_title,
            logo_tint=logo_tint,
        )
    except (ValueError, TypeError) as exc:
        _error(str(exc))
        raise typer.Exit(1)

    fmt = _validate_format(format)
    if fmt:
        svg_path = str(Path(output_path or ".") / badge_name)
        typer.echo(_format_snippet(svg_path, left_text, fmt))

batch(config_file: Path = typer.Argument(..., exists=True, help='Path to JSON config file containing badge definitions'), output_path: Optional[str] = typer.Option(None, help='Output directory; defaults to current directory'), template: str = typer.Option('DEFAULT', help='DEFAULT | CIRCLE | CIRCLE_FRAME | PILL | BANNER'), log_level: str = typer.Option('INFO', help='DEBUG | INFO | WARNING | ERROR | CRITICAL'), max_workers: int = typer.Option(4, help='Parallel worker threads'), style: str = typer.Option('flat', help="FLAT | ROUNDED | GRADIENT | SHADOWED (per-entry 'style' key overrides this)"), format: Optional[str] = typer.Option(None, '--format', help='Print an embed snippet per badge to stdout: markdown | rst | html')) -> None

Batch-generate SVG badges from a JSON config file.

CONFIG_FILE must be a JSON array of badge objects. Required keys per entry: left_text, left_color, badge_name (must end with .svg). Optional keys: right_text, right_color, logo, style, and any other BadgeGenerator kwarg.

Source code in src/badgeshield/generate_badge_cli.py
Python
@app.command()
def batch(
    config_file: Path = typer.Argument(
        ...,
        exists=True,
        help="Path to JSON config file containing badge definitions",
    ),
    output_path: Optional[str] = typer.Option(
        None, help="Output directory; defaults to current directory"
    ),
    template: str = typer.Option(
        "DEFAULT", help="DEFAULT | CIRCLE | CIRCLE_FRAME | PILL | BANNER"
    ),
    log_level: str = typer.Option(
        "INFO", help="DEBUG | INFO | WARNING | ERROR | CRITICAL"
    ),
    max_workers: int = typer.Option(4, help="Parallel worker threads"),
    style: str = typer.Option(
        "flat",
        help="FLAT | ROUNDED | GRADIENT | SHADOWED (per-entry 'style' key overrides this)",
    ),
    format: Optional[str] = typer.Option(
        None,
        "--format",
        help="Print an embed snippet per badge to stdout: markdown | rst | html",
    ),
) -> None:
    """Batch-generate SVG badges from a JSON config file.

    CONFIG_FILE must be a JSON array of badge objects. Required keys per entry:
    left_text, left_color, badge_name (must end with .svg). Optional keys:
    right_text, right_color, logo, style, and any other BadgeGenerator kwarg.
    """
    # --- Validate log_level ---
    try:
        log_level_enum = LogLevel[log_level.upper()]
    except KeyError:
        _error(
            f"Invalid log_level '{log_level}'. "
            f"Choose from: {', '.join(lv.name for lv in LogLevel)}"
        )
        raise typer.Exit(1)

    # --- Validate template ---
    try:
        template_enum = BadgeTemplate[template.upper()]
    except KeyError:
        _error(
            f"Invalid template '{template}'. "
            f"Choose from: {', '.join(tmpl.name for tmpl in BadgeTemplate)}"
        )
        raise typer.Exit(1)

    # --- Validate style ---
    try:
        style_enum = BadgeStyle[style.upper()]
    except KeyError:
        _error(
            f"Invalid style '{style}'. "
            f"Choose from: {', '.join(s.name for s in BadgeStyle)}"
        )
        raise typer.Exit(1)

    # --- Parse config ---
    try:
        badge_configs = json.loads(config_file.read_text(encoding="utf-8"))
        if not isinstance(badge_configs, list):
            _error("Config file must contain a JSON array of badge objects.")
            raise typer.Exit(1)
    except json.JSONDecodeError as exc:
        _error(f"Invalid JSON in config file: {exc}")
        raise typer.Exit(1)

    # --- Validate each badge entry has badge_name ---
    for entry in badge_configs:
        if "badge_name" not in entry or not entry["badge_name"].endswith(".svg"):
            _error("Each badge entry must include a 'badge_name' ending with '.svg'.")
            raise typer.Exit(1)

    # --- Inject CLI-level template, output_path, and style ---
    for badge in badge_configs:
        badge["template"] = template_enum
        if output_path is not None:
            badge["output_path"] = output_path
        # Per-entry style takes priority; inject CLI style only if not set
        if "style" not in badge:
            badge["style"] = style_enum
        else:
            # Validate per-entry style string
            try:
                badge["style"] = BadgeStyle[str(badge["style"]).upper()]
            except KeyError:
                _error(
                    f"Invalid per-entry style '{badge['style']}'. "
                    f"Choose from: {', '.join(s.name for s in BadgeStyle)}"
                )
                raise typer.Exit(1)

    # --- CIRCLE_FRAME requires 'frame' in every badge entry ---
    if template_enum == BadgeTemplate.CIRCLE_FRAME:
        for badge in badge_configs:
            if "frame" not in badge:
                _error(
                    "CIRCLE_FRAME template requires a 'frame' key in every badge entry."
                )
                raise typer.Exit(1)

    # --- Run with Rich progress ---
    batch_gen = BadgeBatchGenerator(max_workers=max_workers, log_level=log_level_enum)
    total = len(badge_configs)

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        TextColumn("{task.completed}/{task.total}"),
    ) as progress:
        task = progress.add_task("Generating badges...", total=total)

        try:
            batch_gen.generate_batch(
                badge_configs,
                progress_callback=lambda name: progress.advance(task),
            )
        except RuntimeError:
            pass  # failures surfaced via summary table

    # --- Print summary table ---
    table = Table(title="Batch Results", show_lines=True)
    table.add_column("Badge", style="cyan")
    table.add_column("Status")
    table.add_column("Error", style="red")

    failure_map = {name: err for name, err in batch_gen._failures}
    for badge in badge_configs:
        name = badge["badge_name"]
        if name in failure_map:
            table.add_row(name, "[red]βœ— FAIL[/red]", failure_map[name])
        else:
            table.add_row(name, "[green]βœ“ OK[/green]", "")

    rprint(table)

    fmt = _validate_format(format)
    if fmt:
        for badge in badge_configs:
            if badge["badge_name"] not in failure_map:
                svg_path = str(Path(output_path or ".") / badge["badge_name"])
                typer.echo(
                    _format_snippet(
                        svg_path, badge.get("left_text", badge["badge_name"]), fmt
                    )
                )

    if batch_gen._failures:
        raise typer.Exit(1)

coverage(input: Path = typer.Argument(..., help='Path to coverage.xml'), badge_name: str = typer.Option(..., help='Output SVG filename (must end with .svg)'), output_path: Optional[str] = typer.Option(None, help='Output directory; defaults to CWD'), metric: str = typer.Option('line', help="Coverage metric: 'line' or 'branch'"), left_text: str = typer.Option('coverage', help='Left segment label'), log_level: str = typer.Option('INFO', help='Logging verbosity'), format: Optional[str] = typer.Option(None, '--format', help='Embed snippet format: markdown | rst | html')) -> None

Generate a coverage badge from a coverage.xml report.

Source code in src/badgeshield/generate_badge_cli.py
Python
@app.command()
def coverage(
    input: Path = typer.Argument(..., help="Path to coverage.xml"),
    badge_name: str = typer.Option(
        ..., help="Output SVG filename (must end with .svg)"
    ),
    output_path: Optional[str] = typer.Option(
        None, help="Output directory; defaults to CWD"
    ),
    metric: str = typer.Option("line", help="Coverage metric: 'line' or 'branch'"),
    left_text: str = typer.Option("coverage", help="Left segment label"),
    log_level: str = typer.Option("INFO", help="Logging verbosity"),
    format: Optional[str] = typer.Option(
        None, "--format", help="Embed snippet format: markdown | rst | html"
    ),
) -> None:
    """Generate a coverage badge from a coverage.xml report."""
    try:
        log_level_enum = LogLevel[log_level.upper()]
    except KeyError:
        _error(
            f"Invalid log_level '{log_level}'. Choose from: {', '.join(lv.name for lv in LogLevel)}"
        )
        raise typer.Exit(1)

    try:
        pct = parse_coverage_xml(input, metric=metric)
    except (FileNotFoundError, ValueError, ET.ParseError) as exc:
        _error(str(exc))
        raise typer.Exit(1)

    right_color = coverage_color(pct)
    right_text = f"{pct:.0f}%"

    try:
        gen = BadgeGenerator(template=BadgeTemplate.DEFAULT, log_level=log_level_enum)
        gen.generate_badge(
            left_text=left_text,
            left_color="#555555",
            right_text=right_text,
            right_color=right_color,
            badge_name=badge_name,
            output_path=output_path,
        )
    except (ValueError, TypeError) as exc:
        _error(str(exc))
        raise typer.Exit(1)

    typer.echo(f"Coverage badge generated: {pct:.1f}% ({metric} coverage)")

    fmt = _validate_format(format)
    if fmt:
        svg_path = str(Path(output_path or ".") / badge_name)
        typer.echo(_format_snippet(svg_path, left_text, fmt))

audit(svg_file: Path = typer.Argument(..., help='Path to SVG file to audit'), json_output: bool = typer.Option(False, '--json', help='Output machine-readable JSON')) -> None

Audit an SVG file for external resource references.

Scans element attributes and inline style url() values. Does not scan element text content or script blocks.

Source code in src/badgeshield/generate_badge_cli.py
Python
@app.command()
def audit(
    svg_file: Path = typer.Argument(..., help="Path to SVG file to audit"),
    json_output: bool = typer.Option(
        False, "--json", help="Output machine-readable JSON"
    ),
) -> None:
    """Audit an SVG file for external resource references.

    Scans element attributes and inline style url() values. Does not scan
    element text content or script blocks.
    """
    try:
        tree = ET.parse(svg_file)
    except FileNotFoundError:
        _error(f"File not found: {svg_file}")
        raise typer.Exit(2)
    except ET.ParseError as exc:
        _error(f"XML parse error: {exc}")
        raise typer.Exit(2)

    root = tree.getroot()
    tag = root.tag
    if not (tag == "svg" or tag.endswith("}svg")):
        _error(f"Root element is <{tag}>, not <svg>. Not an SVG file.")
        raise typer.Exit(2)

    violations = []
    for elem in root.iter():
        for attr_name, attr_value in elem.attrib.items():
            if attr_value.startswith("http://") or attr_value.startswith("https://"):
                violations.append(
                    {
                        "element": elem.tag,
                        "attribute": attr_name,
                        "url": attr_value,
                    }
                )
            if attr_name == "style":
                for match in re.findall(
                    r'url\(["\']?(https?://[^"\')\s]+)', attr_value
                ):
                    violations.append(
                        {
                            "element": elem.tag,
                            "attribute": "style[url]",
                            "url": match,
                        }
                    )

    if json_output:
        typer.echo(
            json.dumps({"clean": len(violations) == 0, "violations": violations})
        )
    else:
        if not violations:
            rprint(
                "[green]\u2713 Clean \u2014 no external resource references found.[/green]"
            )
        else:
            table = Table(title="External URL Violations", show_lines=True)
            table.add_column("Element", style="cyan")
            table.add_column("Attribute", style="yellow")
            table.add_column("URL", style="red", no_wrap=True)
            for v in violations:
                table.add_row(v["element"], v["attribute"], v["url"])
            rprint(table)

    if violations:
        raise typer.Exit(1)

presets_list() -> None

List all available badge presets.

Source code in src/badgeshield/generate_badge_cli.py
Python
@app.command(name="presets")
def presets_list() -> None:
    """List all available badge presets."""
    table = Table(title="Available Presets", show_lines=True)
    table.add_column("Name", style="cyan")
    table.add_column("Label")
    table.add_column("Type")
    table.add_column("Description")
    for name, preset in PRESETS.items():
        kind = (
            "data-wired"
            if (preset.source is not None or name in ("tests", "coverage"))
            else "cosmetic"
        )
        table.add_row(name, preset.label, kind, preset.description)
    rprint(table)

preset_cmd(name: Optional[str] = typer.Argument(None, help="Preset name (see 'badgeshield presets')"), badge_name: Optional[str] = typer.Option(None, '--badge-name', help='Output filename (default: <preset-name>.svg)'), output_path: Optional[str] = typer.Option(None, '--output-path', help='Output directory (default: current directory)'), search_path: str = typer.Option('.', '--search-path', help='Repo root used to resolve data-wired preset values'), style: str = typer.Option('flat', help='FLAT | ROUNDED | GRADIENT | SHADOWED'), format: Optional[str] = typer.Option(None, '--format', help='Print an embed snippet to stdout: markdown | rst | html'), extensions: Optional[List[str]] = typer.Option(None, help="File extensions for 'lines' preset, repeatable (default: .py)"), junit: Optional[Path] = typer.Option(None, '--junit', help="JUnit XML path required by the 'tests' preset"), coverage_xml: Optional[Path] = typer.Option(None, '--coverage-xml', help="coverage.xml path required by the 'coverage' preset"), all_presets: bool = typer.Option(False, '--all', help="Generate all resolvable presets; skips data-wired presets that return 'unknown'")) -> None

Generate a badge from a named preset (see 'badgeshield presets' for the full list).

Data-wired presets read values from your local repo (pyproject.toml, git, source files) with no network calls. Use --all to generate every resolvable preset in one command.

Source code in src/badgeshield/generate_badge_cli.py
Python
@app.command(name="preset")
def preset_cmd(
    name: Optional[str] = typer.Argument(
        None, help="Preset name (see 'badgeshield presets')"
    ),
    badge_name: Optional[str] = typer.Option(
        None, "--badge-name", help="Output filename (default: <preset-name>.svg)"
    ),
    output_path: Optional[str] = typer.Option(
        None, "--output-path", help="Output directory (default: current directory)"
    ),
    search_path: str = typer.Option(
        ".", "--search-path", help="Repo root used to resolve data-wired preset values"
    ),
    style: str = typer.Option("flat", help="FLAT | ROUNDED | GRADIENT | SHADOWED"),
    format: Optional[str] = typer.Option(
        None, "--format", help="Print an embed snippet to stdout: markdown | rst | html"
    ),
    extensions: Optional[List[str]] = typer.Option(
        None, help="File extensions for 'lines' preset, repeatable (default: .py)"
    ),
    junit: Optional[Path] = typer.Option(
        None, "--junit", help="JUnit XML path required by the 'tests' preset"
    ),
    coverage_xml: Optional[Path] = typer.Option(
        None,
        "--coverage-xml",
        help="coverage.xml path required by the 'coverage' preset",
    ),
    all_presets: bool = typer.Option(
        False,
        "--all",
        help="Generate all resolvable presets; skips data-wired presets that return 'unknown'",
    ),
) -> None:
    """Generate a badge from a named preset (see 'badgeshield presets' for the full list).

    Data-wired presets read values from your local repo (pyproject.toml, git,
    source files) with no network calls. Use --all to generate every resolvable
    preset in one command.
    """
    if all_presets:
        _run_all_presets(
            output_path, search_path, style, format, extensions, junit, coverage_xml
        )
        return

    if name is None:
        _error(
            "Provide a preset name or use --all. Run 'badgeshield presets' to list available presets."
        )
        raise typer.Exit(1)

    if name not in PRESETS:
        _error(
            f"Unknown preset '{name}'. Run 'badgeshield presets' to see available options."
        )
        raise typer.Exit(1)

    p = PRESETS[name]
    out_name = badge_name or f"{name}.svg"
    sp = Path(search_path)

    try:
        style_enum = BadgeStyle[style.upper()]
    except KeyError:
        _error(
            f"Invalid style '{style}'. Choose from: {', '.join(s.name for s in BadgeStyle)}"
        )
        raise typer.Exit(1)

    # Resolve right_text
    right_text = p.right_text
    if name == "lines":
        ext_tuple = tuple(extensions) if extensions else (".py",)
        try:
            right_text = get_lines_of_code(sp, extensions=ext_tuple)
        except Exception as exc:
            _error(str(exc))
            raise typer.Exit(1)
        if right_text == "0":
            _error("No source files matched β€” 'lines' preset resolved to 0.")
            raise typer.Exit(1)
    elif name == "tests":
        if junit is None:
            _error("The 'tests' preset requires --junit <path-to-junit.xml>")
            raise typer.Exit(1)
        try:
            right_text = get_test_results(junit)
        except Exception as exc:
            _error(str(exc))
            raise typer.Exit(1)
    elif name == "coverage":
        if coverage_xml is None:
            _error(
                "The 'coverage' preset requires --coverage-xml <path-to-coverage.xml>"
            )
            raise typer.Exit(1)
        try:
            right_text = get_coverage(coverage_xml)
        except Exception as exc:
            _error(str(exc))
            raise typer.Exit(1)
    elif p.source is not None:
        try:
            right_text = p.source(sp)
        except Exception as exc:
            _error(str(exc))
            raise typer.Exit(1)

    left_color = str(p.color)

    try:
        gen = BadgeGenerator(template=BadgeTemplate.DEFAULT, style=style_enum)
        gen.generate_badge(
            left_text=p.label,
            left_color=left_color,
            right_text=right_text,
            right_color=p.right_color,
            badge_name=out_name,
            output_path=output_path,
        )
    except (ValueError, TypeError) as exc:
        _error(str(exc))
        raise typer.Exit(1)

    fmt = _validate_format(format)
    if fmt:
        svg_path = str(Path(output_path or ".") / out_name)
        typer.echo(_format_snippet(svg_path, p.label, fmt))