From 07f0373b562e2e40d043698cfcb11b0cda8c6fa2 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 17:50:56 +0200 Subject: [PATCH] feat(cli): add `codecarbon badge` command Render a README badge locally from an existing emissions.csv: a flat SVG and a shields.io endpoint JSON, plus the markdown snippet to paste. No network call, no hosted service, no new dependency. The badge is colour-neutral by default. CodeCarbon values are often estimates, and no fixed gram threshold is meaningful across arbitrary workloads, so automatic green/red colouring would be greenwashing. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/badge.py | 205 +++++++++++++++++++++++++++++++++++++++ codecarbon/cli/main.py | 67 ++++++++++++- docs/how-to/visualize.md | 5 + docs/reference/cli.md | 52 ++++++++++ tests/test_badge.py | 122 +++++++++++++++++++++++ 5 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 codecarbon/badge.py create mode 100644 tests/test_badge.py diff --git a/codecarbon/badge.py b/codecarbon/badge.py new file mode 100644 index 000000000..3788abf1b --- /dev/null +++ b/codecarbon/badge.py @@ -0,0 +1,205 @@ +""" +Generate a README badge from an existing ``emissions.csv``. + +Everything here is local: read the CSV written by ``FileOutput``, render a flat +SVG badge and a `shields.io endpoint +`_ JSON file. No network call, no +hosted service, no account. +""" + +import csv +import json +from pathlib import Path +from typing import Dict, List, Optional + +DEFAULT_LABEL = "carbon" +DEFAULT_COLOR = "#9f9f9f" +BADGE_STEM = "codecarbon-badge" + +# The badge is deliberately colour-neutral by default. CodeCarbon numbers are +# often estimates, and a green badge on a large model would be greenwashing: +# no gram threshold is meaningful across arbitrary workloads. + +_SVG_TEMPLATE = """ + {label}: {message} + + + + + + + + + + + + {label} + {label} + {message} + {message} + + +""" + + +def load_runs(emissions_file, project: Optional[str] = None) -> List[Dict]: + """ + Read the rows of an emissions.csv, optionally keeping a single project. + """ + path = Path(emissions_file) + if not path.is_file(): + raise FileNotFoundError(f"No emissions file at {path}") + with path.open(newline="", encoding="utf-8") as file: + rows = list(csv.DictReader(file)) + if project is not None: + rows = [row for row in rows if row.get("project_name") == project] + if not rows: + raise ValueError( + f"No rows found in {path}" + + (f" for project '{project}'" if project else "") + ) + return rows + + +def summarise(rows: List[Dict], select: str = "last") -> Dict[str, float]: + """ + Reduce the rows to the emissions and energy of a single reported value. + """ + emissions = [float(row["emissions"]) for row in rows] + energy = [float(row["energy_consumed"]) for row in rows] + if select == "last": + value = {"emissions": emissions[-1], "energy_consumed": energy[-1]} + elif select == "mean": + value = { + "emissions": sum(emissions) / len(emissions), + "energy_consumed": sum(energy) / len(energy), + } + elif select == "total": + value = {"emissions": sum(emissions), "energy_consumed": sum(energy)} + else: + raise ValueError(f"Unknown selection '{select}', expected last/mean/total") + value["runs"] = len(rows) + return value + + +def format_value(kilos: float, unit: str = "gCO2eq") -> str: + """ + Format a value given in kg (or kWh) with a sensible scale, 3 significant + digits. ``unit`` is the gram-scale (or Wh-scale) unit name. + """ + scaled, prefix = abs(kilos) * 1000, "" + if scaled < 1: + scaled, prefix = scaled * 1000, "m" + elif scaled >= 1_000_000: + scaled, prefix = scaled / 1_000_000, "M" + elif scaled >= 1000: + scaled, prefix = scaled / 1000, "k" + return f"{scaled:.3g} {prefix}{unit}" + + +def _text_width(text: str) -> int: + # ponytail: character count times an average advance; real font metrics + # would need a font dependency and would move the badge by a pixel or two. + return int(len(text) * 6.5) + 20 + + +def render_svg(label: str, message: str, color: str = DEFAULT_COLOR) -> str: + label_width = _text_width(label) + message_width = _text_width(message) + return _SVG_TEMPLATE.format( + label=_escape(label), + message=_escape(message), + color=color, + width=label_width + message_width, + label_width=label_width, + message_width=message_width, + label_x=label_width // 2, + message_x=label_width + message_width // 2, + ) + + +def _escape(text: str) -> str: + for char, entity in (("&", "&"), ("<", "<"), (">", ">")): + text = text.replace(char, entity) + return text + + +def render_endpoint_json(label: str, message: str, color: str = DEFAULT_COLOR) -> str: + return json.dumps( + { + "schemaVersion": 1, + "label": label, + "message": message, + "color": color, + }, + indent=2, + ) + + +def render_markdown(label: str, output_dir=".") -> str: + return ( + f"![{label}]({Path(output_dir) / (BADGE_STEM + '.svg')})\n" + "\n" + "or, publishing the endpoint JSON at a public URL:\n" + "\n" + f"![{label}](https://img.shields.io/endpoint?url=/{BADGE_STEM}.json)" + ) + + +def message_for( + summary: Dict[str, float], select: str = "last", metric: str = "emissions" +) -> str: + """ + Build the right-hand side of the badge from a summary. + """ + suffix = {"last": "", "mean": "/run", "total": " total"}[select] + parts = [] + if metric in ("emissions", "both"): + parts.append(format_value(summary["emissions"], "gCO2eq")) + if metric in ("energy", "both"): + parts.append(format_value(summary["energy_consumed"], "Wh")) + return " | ".join(parts) + suffix + + +def render( + emissions_file="emissions.csv", + project: Optional[str] = None, + select: str = "last", + metric: str = "emissions", + label: str = DEFAULT_LABEL, + color: str = DEFAULT_COLOR, +) -> str: + """ + Return the SVG source of the badge for an emissions file. + """ + summary = summarise(load_runs(emissions_file, project), select) + return render_svg(label, message_for(summary, select, metric), color) + + +def write( + emissions_file="emissions.csv", + project: Optional[str] = None, + select: str = "last", + metric: str = "emissions", + label: str = DEFAULT_LABEL, + color: str = DEFAULT_COLOR, + output_dir=".", + formats=("svg", "json"), +) -> List[Path]: + """ + Write the badge files and return the paths written. + """ + summary = summarise(load_runs(emissions_file, project), select) + message = message_for(summary, select, metric) + directory = Path(output_dir) + directory.mkdir(parents=True, exist_ok=True) + written = [] + if "svg" in formats: + path = directory / f"{BADGE_STEM}.svg" + path.write_text(render_svg(label, message, color), encoding="utf-8") + written.append(path) + if "json" in formats: + path = directory / f"{BADGE_STEM}.json" + path.write_text(render_endpoint_json(label, message, color), encoding="utf-8") + written.append(path) + return written diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..968b80dfc 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -7,10 +7,11 @@ import typer from rich import print +from rich.markup import escape from rich.prompt import Confirm from typing_extensions import Annotated -from codecarbon import __app_name__, __version__ +from codecarbon import __app_name__, __version__, badge from codecarbon.cli.cli_utils import ( create_new_config_file, get_api_endpoint, @@ -492,6 +493,70 @@ def detect(): print(f"- GPU model: {gpu_model_str}") +@codecarbon.command("badge", short_help="Generate a README badge from emissions.csv.") +def badge_command( + file: Path = typer.Option( + Path("./emissions.csv"), "--file", help="Emissions file to read." + ), + project: Optional[str] = typer.Option( + None, "--project", help="Only use rows of this project." + ), + select: str = typer.Option( + "last", "--select", help="Which run(s) to report: last, mean or total." + ), + metric: str = typer.Option( + "emissions", "--metric", help="What to show: emissions, energy or both." + ), + output_dir: Path = typer.Option( + Path("."), "--output-dir", help="Where to write the badge files." + ), + label: str = typer.Option( + badge.DEFAULT_LABEL, "--label", help="Left-hand badge text." + ), + color: str = typer.Option( + badge.DEFAULT_COLOR, "--color", help="Badge colour, neutral grey by default." + ), + output_format: str = typer.Option( + "all", "--format", help="Files to write: svg, json or all." + ), +): + """ + Generate a badge for your README from an existing emissions file. + + Nothing leaves your machine : the badge is rendered locally from the CSV. + """ + formats = ("svg", "json") if output_format == "all" else (output_format,) + try: + rows = badge.load_runs(file, project) + summary = badge.summarise(rows, select) + paths = badge.write( + emissions_file=file, + project=project, + select=select, + metric=metric, + label=label, + color=color, + output_dir=output_dir, + formats=formats, + ) + except (FileNotFoundError, ValueError, KeyError) as error: + print(f"[bold red]{escape(str(error))}[/]") + raise typer.Exit(1) + + print( + f"Read {summary['runs']} row(s) from {file}" + + (f" (project={project})" if project else "") + ) + print( + f"{select}: {badge.format_value(summary['emissions'], 'gCO2eq')}, " + f"{badge.format_value(summary['energy_consumed'], 'Wh')}" + ) + for path in paths: + print(f"Wrote {path}") + print("\nPaste into your README:\n") + print(escape(badge.render_markdown(label, output_dir))) + + def questionary_prompt(prompt, list_options, default): import questionary diff --git a/docs/how-to/visualize.md b/docs/how-to/visualize.md index f307d4b48..7a29e4a38 100644 --- a/docs/how-to/visualize.md +++ b/docs/how-to/visualize.md @@ -105,3 +105,8 @@ The app also provides a visualization of regional carbon intensity of electricit - [Configure CodeCarbon](configuration.md) for additional tracking options - [Integrate with experiment tracking tools](comet.md) like Comet for seamless workflow integration - [Join our Discord](https://discord.gg/GS9js2XkJR) to share your results and discuss emissions tracking with the community + +## Generate a README badge + +To turn a run into a badge you can commit next to your CI badges, see +[`codecarbon badge`](../reference/cli.md#codecarbon-badge). diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71ba05a85..68d6d7ccb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -98,3 +98,55 @@ codecarbon detect ``` Displays detected RAM, CPU, GPU, and other hardware information that CodeCarbon uses to estimate energy consumption. Useful for verifying that CodeCarbon can see all your hardware. + +### `codecarbon badge` + +Generate a README badge from an existing `emissions.csv`. + +**Usage:** +```bash +codecarbon badge [OPTIONS] +``` + +Reads the emissions file, writes `codecarbon-badge.svg` and `codecarbon-badge.json` +(a [shields.io endpoint](https://shields.io/badges/endpoint-badge) file), and prints +a markdown snippet to paste into your README. Everything happens locally: no network +call, no account, no data leaves your machine. + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `--file` | path | ./emissions.csv | Emissions file to read | +| `--project` | string | - | Only use rows of this project | +| `--select` | choice | last | Which run(s) to report: `last`, `mean` or `total` | +| `--metric` | choice | emissions | What to show: `emissions`, `energy` or `both` | +| `--output-dir` | path | . | Where to write the badge files | +| `--label` | string | carbon | Left-hand badge text | +| `--color` | string | grey | Badge colour | +| `--format` | choice | all | Files to write: `svg`, `json` or `all` | + +**Examples:** +```bash +# Badge for the last run +codecarbon badge + +# Average over every run of one project, showing energy too +codecarbon badge --project my-training --select mean --metric both + +# Write into the docs assets folder +codecarbon badge --output-dir docs/assets +``` + +The badge is colour-neutral by default, and reports exactly what the CSV contains. +Keep in mind that CodeCarbon values are estimates: CPU power may come from a TDP +model and carbon intensity is often a country average, so a badge is a useful +order-of-magnitude signal rather than an audited figure. If you want a colour, pass +`--color` explicitly. + +You can regenerate the badge in CI after a benchmark job and commit the SVG, or +publish `codecarbon-badge.json` at a public URL and point shields.io at it: + +```markdown +![carbon](https://img.shields.io/endpoint?url=https://example.org/codecarbon-badge.json) +``` diff --git a/tests/test_badge.py b/tests/test_badge.py new file mode 100644 index 000000000..5aee4c96a --- /dev/null +++ b/tests/test_badge.py @@ -0,0 +1,122 @@ +import json +import xml.etree.ElementTree as ET + +import pytest +from typer.testing import CliRunner + +from codecarbon import badge +from codecarbon.cli.main import codecarbon as cli_app + +CSV_CONTENT = """timestamp,project_name,emissions,energy_consumed +2024-01-01T00:00:00,alpha,0.001,0.010 +2024-01-02T00:00:00,beta,0.500,1.000 +2024-01-03T00:00:00,alpha,0.003,0.020 +""" + + +@pytest.fixture +def emissions_file(tmp_path): + path = tmp_path / "emissions.csv" + path.write_text(CSV_CONTENT, encoding="utf-8") + return path + + +def test_load_runs_filters_project(emissions_file): + assert len(badge.load_runs(emissions_file)) == 3 + assert len(badge.load_runs(emissions_file, project="alpha")) == 2 + + +def test_load_runs_errors_cleanly(tmp_path, emissions_file): + with pytest.raises(FileNotFoundError): + badge.load_runs(tmp_path / "nope.csv") + with pytest.raises(ValueError): + badge.load_runs(emissions_file, project="gamma") + + +def test_select_last_mean_total(emissions_file): + rows = badge.load_runs(emissions_file, project="alpha") + assert badge.summarise(rows, "last")["emissions"] == pytest.approx(0.003) + assert badge.summarise(rows, "mean")["emissions"] == pytest.approx(0.002) + assert badge.summarise(rows, "total")["emissions"] == pytest.approx(0.004) + assert badge.summarise(rows, "total")["energy_consumed"] == pytest.approx(0.030) + assert badge.summarise(rows, "last")["runs"] == 2 + with pytest.raises(ValueError): + badge.summarise(rows, "median") + + +@pytest.mark.parametrize( + ["value", "expected"], + [ + (0.0000004, "0.4 mgCO2eq"), + (0.004, "4 gCO2eq"), + (0.5, "500 gCO2eq"), + (12.0, "12 kgCO2eq"), + (12000.0, "12 MgCO2eq"), + ], +) +def test_format_value_units(value, expected): + assert badge.format_value(value) == expected + + +def test_message_for_metrics(): + summary = {"emissions": 0.0124, "energy_consumed": 0.031} + assert badge.message_for(summary, "mean") == "12.4 gCO2eq/run" + assert badge.message_for(summary, "total", "both") == "12.4 gCO2eq | 31 Wh total" + + +def test_render_svg_is_wellformed(): + svg = badge.render_svg("carbon", "12.4 gCO2eq/run") + root = ET.fromstring(svg) + texts = [element.text for element in root.iter("{http://www.w3.org/2000/svg}text")] + assert "12.4 gCO2eq/run" in texts + assert "carbon" in texts + + +def test_render_svg_escapes(): + assert "