"""Defines a ChangeTable class for rendering a table of commit changes with statistical metrics."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from jinja2 import Environment
from energytrackr.plot.config import get_settings
from energytrackr.plot.core.context import Context
from energytrackr.plot.core.interfaces import Configurable, PageObj
from energytrackr.utils.logger import logger
from energytrackr.utils.utils import get_local_env
# ---------------------------------------------------------------------------
# Helper - expand column groups & formatting
# ---------------------------------------------------------------------------
group_map = {
"stats": [
("n_val", "n"),
("normality", "Normality (Shapiro-Wilk)"),
("median_val", "Median (J)"),
("std_val", "Std Dev (J)"),
],
"tests": [
("p_value", "p-value (Welch-test)"),
("cohen_str", "Cohen d"),
("effect_cat", "Effect"),
("pct_change", "Δ %"),
("pct_cat", "Δ cat"),
("abs_diff", "Δ J"),
("practical", "Practical"),
],
}
[docs]
@dataclass(frozen=False)
class ChangeTableConfig:
"""Configuration for the ChangeTable class."""
template: Path = Path(__file__).with_name("templates") / "change_table.html"
columns: list[dict[str, str]] | None = None
[docs]
class ChangeTable(PageObj, Configurable[ChangeTableConfig]):
"""Renders a table of every commit with computed metrics."""
def __init__(self, **params: dict[str, Any]) -> None:
"""Initializes the ChangeTable object.
Args:
**params: Additional parameters for configuration.
"""
# Columns and template location
super().__init__(ChangeTableConfig, **params)
cols: list[dict[str, str]] = []
if self.config.columns is None:
self.config.columns = [
{"key": "short_hash", "label": "Commit"},
{"key": "message", "label": "Message"},
{"key": "commit_date", "label": "Date"},
{"key": "commit_files", "label": "Files"},
{"key": "commit_link", "label": "Link"},
{"group": "stats"},
{"group": "tests"},
]
else:
for c in self.config.columns:
if "group" in c:
keys = group_map[c["group"]]
if include := c.get("include"):
keys = [pair for pair in keys if pair[0] in include]
if exclude := c.get("exclude"):
keys = [pair for pair in keys if pair[0] not in exclude]
cols.extend({"key": k, "label": label} for k, label in keys)
else:
cols.append({"key": c["key"], "label": c.get("label", c["key"])})
self.config.columns = cols
@property
def template_path(self) -> Path:
"""Returns the path to the template file."""
return self.config.template
@property
def columns(self) -> list[dict[str, str]]:
"""Returns the list of columns for the change table."""
assert self.config.columns is not None, "Columns not set in config"
return self.config.columns
[docs]
def render(self, env: Environment, ctx: Context) -> str:
"""Renders the change table section as an HTML string using a Jinja2 template.
Args:
env (Environment): The Jinja2 environment used for template rendering.
ctx (Context): The context object containing artefacts, statistics, and energy fields.
Returns:
str: The rendered HTML string for the change table. If the template is missing, returns an error message.
Workflow:
- Checks if the template file exists; logs an error and returns an error message if not found.
- Determines whether to use the provided environment or create a local one based on the template's location.
- Loads the template.
- Prepares table rows by extracting commit and statistical data from the context.
- Formats each row with commit details, statistical values, and change event information.
- Renders the template with the prepared columns and rows.
"""
# Load template
if not self.template_path.is_file():
logger.error("ChangeTable: template '%s' not found.", self.template_path)
return "<p><strong>Error:</strong> change table template missing.</p>"
tmpl = get_local_env(env, str(self.template_path)).get_template(self.template_path.name)
# Prepare rows
logger.info("energy fields: %s", ctx.energy_fields)
logger.info("stats fields: %s", ctx.stats.keys())
col = ctx.energy_fields[0]
stats = ctx.stats
rows = {e.index: e for e in ctx.artefacts["change_events"]}
df_m = stats["df_median"]
table_rows = []
for i, commit in enumerate(stats["valid_commits"]):
rs = df_m[df_m["commit"] == commit].iloc[0]
ev = rows.get(i)
commit_details = ctx.artefacts["commit_details"].get(commit, {})
table_rows.append({
"short_hash": stats["short_hashes"][i],
"message": commit_details.get("commit_summary", ""),
"commit_date": commit_details.get("commit_date", ""),
"commit_files": commit_details.get("files_modified", ""),
"commit_link": commit_details.get("commit_link", ""),
"n_val": int(rs["count"]),
"normality": "Normal" if ctx.artefacts["normality_flags"][i] else "Non-normal",
"median_val": f"{rs[col]:.2f}",
"std_val": f"{rs[f'{col}_std']:.2f}",
"p_value": f"{ev.p_value:.3g}" if ev else "N/A",
"cohen_str": f"{ev.effect_size.cohen_d:.2f}" if ev else "N/A",
"effect_cat": ev.effect_size.category if ev else "N/A",
"pct_change": f"{ev.change_magnitude.pct_change * 100:.1f}%" if ev else "0%",
"pct_cat": ev.change_magnitude.pct_change_level if ev else "N/A",
"abs_diff": f"{ev.change_magnitude.abs_diff:.2f}" if ev else "0.0",
"practical": ev.change_magnitude.practical_level if ev else "N/A",
"level": ev.level if ev else "-",
"row_class": (
"baseline"
if ev is None and i == 0
else "nochange"
if ev and ev.level == 0
else "increase"
if ev and ev.direction == "increase"
else "decrease"
if ev and ev.direction == "decrease"
else "nochange"
),
})
settings = get_settings()
# Render with Jinja2
return tmpl.render(columns=self.columns, rows=table_rows, font=settings.energytrackr.report.font)