Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions codecarbon/cli/ci_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""
Summarise an ``emissions.csv`` produced by CodeCarbon, optionally against a
baseline run, and render it for a CI job summary or a pull request comment.

Kept free of any CI-vendor specifics so GitLab, Jenkins, Buildkite and GitHub
Actions can all consume it.
"""

import csv
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional


class CIReportError(Exception):
"""Raised when an emissions CSV cannot be read or makes no sense."""


@dataclass
class RunSummary:
"""Totals for a single CodeCarbon run, read from its last CSV row."""

emissions: float
energy_consumed: float
duration: float
rows: int
project_name: str = ""
country_iso_code: str = ""
region: str = ""


def _to_float(value: Optional[str]) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0


def summarise(csv_path: Path) -> RunSummary:
"""
Read the totals of the most recent run in ``csv_path``.

During a run CodeCarbon appends one row per flush, all sharing the same
``run_id``, and every row holds the *cumulative* totals since the start of
the run -- ``FileOutput.out()`` discards the delta it is handed and writes
the running total. The last row of the last ``run_id`` is therefore the
total of that run, and degrades to the single row when the run was written
only once.
"""
if not csv_path.is_file():
raise CIReportError(f"No emissions file found at {csv_path}")

with open(csv_path, newline="") as csv_file:
rows: List[Dict[str, str]] = list(csv.DictReader(csv_file))

if not rows:
raise CIReportError(f"No emissions data in {csv_path}")
if "emissions" not in rows[0]:
raise CIReportError(
f"{csv_path} does not look like a CodeCarbon emissions file"
" (no 'emissions' column)"
)

last_run_id = rows[-1].get("run_id")
run_rows = [row for row in rows if row.get("run_id") == last_run_id]

last = run_rows[-1]
return RunSummary(
emissions=_to_float(last.get("emissions")),
energy_consumed=_to_float(last.get("energy_consumed")),
duration=_to_float(last.get("duration")),
rows=len(run_rows),
project_name=last.get("project_name") or "",
country_iso_code=last.get("country_iso_code") or "",
region=last.get("region") or "",
)


def _format_delta(current: float, baseline: float) -> str:
delta = current - baseline
sign = "+" if delta >= 0 else "-"
text = f"{sign}{abs(delta) * 1000:.1f} g"
if baseline:
text += f" ({sign}{abs(delta) / baseline * 100:.0f}%)"
return text


def render_markdown(
summary: RunSummary,
baseline: Optional[RunSummary] = None,
label: str = "",
) -> str:
header = "**🌱 CodeCarbon**"
if label:
header += f" — `{label}`"
elif summary.project_name:
header += f" — `{summary.project_name}`"

line = (
f"**{summary.emissions * 1000:.1f} g CO2eq**"
f" ({summary.energy_consumed:.3f} kWh, {summary.duration:.0f} s)"
)
if baseline is not None:
line += (
f" — **{_format_delta(summary.emissions, baseline.emissions)}** vs baseline"
)

location = summary.region or summary.country_iso_code
footer = "Measured with CodeCarbon"
if location:
footer += f" in `{location}`"
footer += (
". On virtualised CI runners CPU energy is estimated from the CPU model TDP,"
" so values are comparable between runs on identical runners"
" rather than absolute."
)

return f"{header}\n{line}\n\n_{footer}_"


def render_json(
summary: RunSummary,
baseline: Optional[RunSummary] = None,
label: str = "",
) -> str:
payload = {
"label": label or summary.project_name,
"emissions_kg": summary.emissions,
"energy_kwh": summary.energy_consumed,
"duration_seconds": summary.duration,
"country_iso_code": summary.country_iso_code,
"region": summary.region,
"baseline_emissions_kg": None if baseline is None else baseline.emissions,
"delta_kg": (
None if baseline is None else summary.emissions - baseline.emissions
),
}
return json.dumps(payload, indent=2)


def render(
summary: RunSummary,
baseline: Optional[RunSummary] = None,
label: str = "",
output_format: str = "markdown",
) -> str:
"""Render a summary in the requested format."""
if output_format == "markdown":
return render_markdown(summary, baseline, label)
if output_format == "json":
return render_json(summary, baseline, label)
raise CIReportError(
f"Unknown format '{output_format}' (should be 'markdown' or 'json')"
)
63 changes: 63 additions & 0 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,69 @@ def signal_handler(signum, frame):
raise e


@codecarbon.command(
"ci-report", short_help="Summarise an emissions.csv, for CI pipelines."
)
def ci_report(
csv: Annotated[
Path,
typer.Option(help="Emissions CSV to summarise."),
] = Path("emissions.csv"),
baseline: Annotated[
Optional[Path],
typer.Option(help="Emissions CSV to compare against, e.g. the target branch."),
] = None,
output_format: Annotated[
str,
typer.Option("--format", help="Output format: markdown or json."),
] = "markdown",
label: Annotated[
str,
typer.Option(help="Label for the measured workload, shown in the report."),
] = "",
threshold_kg: Annotated[
Optional[float],
typer.Option(help="Exit with code 1 above this many kgCO2eq."),
] = None,
):
"""
Summarise a CodeCarbon run for a CI job summary or a pull request comment.

Reads the totals of the most recent run in the CSV, optionally compares
them with a baseline run, and prints markdown or JSON. Nothing here is
specific to a CI provider.

Examples:

codecarbon ci-report --csv emissions.csv

codecarbon ci-report --baseline base/emissions.csv --label "pytest -q"

codecarbon ci-report --format json --threshold-kg 0.05
"""
from codecarbon.cli.ci_report import CIReportError, render, summarise

try:
summary = summarise(csv)
baseline_summary = (
summarise(baseline) if baseline is not None and baseline.is_file() else None
)
report = render(summary, baseline_summary, label, output_format)
except CIReportError as e:
print(f"[bold red]Error:[/bold red] {e}")
raise typer.Exit(1)

# print() is Rich's, which would reflow and style the markdown
sys.stdout.write(report + "\n")

if threshold_kg is not None and summary.emissions > threshold_kg:
print(
f"[bold red]Emissions above threshold:[/bold red]"
f" {summary.emissions:.6f} kgCO2eq > {threshold_kg} kgCO2eq"
)
raise typer.Exit(1)


@codecarbon.command("detect", short_help="Detect hardware and print information.")
def detect():
"""
Expand Down
47 changes: 47 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,50 @@ 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 ci-report`

Summarise an `emissions.csv` for a CI pipeline, optionally against a baseline run.

**Usage:**
```bash
codecarbon ci-report [OPTIONS]
```

Reads the totals of the most recent run in the CSV and prints markdown or JSON. CodeCarbon appends one row per flush, all sharing the same `run_id`, and each row holds the cumulative totals since the start of the run, so the last row of the last `run_id` is used. Nothing in the command is specific to a CI provider, so it works the same on GitHub Actions, GitLab CI, Jenkins or Buildkite.

**Options:**

| Option | Default | Description |
|---|---|---|
| `--csv PATH` | `emissions.csv` | Emissions CSV to summarise. |
| `--baseline PATH` | none | Emissions CSV to compare against, typically the target branch. A missing file is ignored and the comparison is simply omitted. |
| `--format [markdown\|json]` | `markdown` | Output format. |
| `--label TEXT` | project name | Label for the measured workload, shown in the report header. |
| `--threshold-kg FLOAT` | none | Exit with code 1 when total emissions exceed this many kgCO2eq. |

**Examples:**
```bash
# Measure a test suite, then report on it
codecarbon monitor -- pytest -q
codecarbon ci-report --label "pytest -q"

# Compare with a baseline downloaded from the target branch
codecarbon ci-report --baseline base/emissions.csv --label "pytest -q"

# Fail the pipeline above a budget
codecarbon ci-report --threshold-kg 0.05

# Machine-readable output for further processing
codecarbon ci-report --format json
```

Writing the markdown to a GitHub Actions job summary needs no token and no extra permissions:

```yaml
- run: codecarbon monitor -- pytest -q
- run: codecarbon ci-report --label "pytest -q" >> "$GITHUB_STEP_SUMMARY"
```

!!! warning "Accuracy on hosted CI runners"
Hosted runners are virtualised, so RAPL is unavailable and CPU energy falls back to an estimate based on the CPU model TDP. Numbers are meaningful when comparing runs on identical runner types, not as absolute figures. For accurate measurements use a self-hosted runner with [RAPL enabled](../how-to/enable-rapl.md).
Loading
Loading