From 04f2f8ea5d2dcdbd53983c4ddbbbd6584676093c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 12 Aug 2025 17:00:32 -0400
Subject: [PATCH 01/54] a very very rough draft, does not work for polars yet
---
great_tables/_modify_rows.py | 86 +++++++++++++++++++++++++++++++++++-
great_tables/_tbl_data.py | 53 ++++++++++++++++++++++
great_tables/gt.py | 3 +-
3 files changed, 139 insertions(+), 3 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 4ee30ae74..b25143a0e 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -1,8 +1,18 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from statistics import quantiles
+from typing import TYPE_CHECKING, Literal
+
+from great_tables._locations import resolve_cols_c
from ._gt_data import Locale, RowGroups, Styles
+from ._tbl_data import (
+ SelectExpr,
+ copy_data,
+ get_column_names,
+ insert_row,
+ to_list,
+)
if TYPE_CHECKING:
from ._types import GTSelf
@@ -16,8 +26,8 @@ def row_group_order(self: GTSelf, groups: RowGroups) -> GTSelf:
def _remove_from_body_styles(styles: Styles, column: str) -> Styles:
# TODO: refactor
- from ._utils_render_html import _is_loc
from ._locations import LocBody
+ from ._utils_render_html import _is_loc
new_styles = [
info for info in styles if not (_is_loc(info.locname, LocBody) and info.colname == column)
@@ -178,3 +188,75 @@ def with_id(self: GTSelf, id: str | None = None) -> GTSelf:
```
"""
return self._replace(_options=self._options._set_option_value("table_id", id))
+
+
+# def grand_summary_rows(
+# self: GTSelf,
+# fns: list[Literal["min", "max", "mean", "median"]] | Literal["min", "max", "mean", "median"],
+# columns: SelectExpr = None,
+# side: Literal["bottom", "top"] = "bottom",
+# ) -> GTSelf:
+# new_body = self._body.copy()
+# new_tbl_data = new_body.body
+
+
+# new_body.append()
+
+# self._replace(_body=new_body)
+
+# return self
+
+
+def grand_summary_rows(
+ self: GTSelf,
+ fns: list[Literal["min", "max", "mean", "median"]] | Literal["min", "max", "mean", "median"],
+ columns: SelectExpr = None,
+ side: Literal["bottom", "top"] = "bottom",
+ missing_text: str = "---",
+) -> GTSelf:
+ if isinstance(fns, str):
+ fns = [fns]
+
+ tbl_data = self._tbl_data
+
+ new_tbl_data = copy_data(tbl_data)
+
+ original_column_names = get_column_names(tbl_data)
+
+ summary_col_names = resolve_cols_c(data=self, expr=columns)
+
+ # Create summary rows DataFrame
+ for fn_name in fns:
+ summary_row = []
+
+ for col in original_column_names:
+ if col in summary_col_names:
+ col_data = to_list(tbl_data[col])
+
+ if fn_name == "min":
+ new_cell = [str(min(col_data))]
+ elif fn_name == "max":
+ new_cell = [str(max(col_data))]
+ elif fn_name == "mean":
+ new_cell = [str(sum(col_data) / len(col_data))]
+ elif fn_name == "median":
+ new_cell = [str(quantiles(col_data, n=2))]
+ else:
+ # Should never get here
+ new_cell = ["hi"]
+ else:
+ new_cell = [missing_text]
+
+ summary_row += new_cell
+
+ new_tbl_data = insert_row(new_tbl_data, summary_row, 0)
+
+ # Concatenate based on side parameter
+ # if side == "bottom":
+ # new_data = concat_frames(tbl_data, summary_df)
+ # else: # top
+ # new_data = concat_frames(summary_df, tbl_data)
+
+ print(new_tbl_data)
+
+ return self._replace(_tbl_data=new_tbl_data)
diff --git a/great_tables/_tbl_data.py b/great_tables/_tbl_data.py
index 43798e364..fbe0dea97 100644
--- a/great_tables/_tbl_data.py
+++ b/great_tables/_tbl_data.py
@@ -874,3 +874,56 @@ def _(ser: PyArrowChunkedArray, name: Optional[str] = None) -> PyArrowTable:
import pyarrow as pa
return pa.table({name: ser})
+
+
+# insert_row ----
+
+
+@singledispatch
+def insert_row(df: DataFrameLike, row_data: list, index: int) -> DataFrameLike:
+ """Insert a single row into a DataFrame at the specified index"""
+ raise NotImplementedError(f"Unsupported type: {type(df)}")
+
+
+@insert_row.register
+def _(df: PdDataFrame, row_data: list, index: int) -> PdDataFrame:
+ import pandas as pd
+
+ new_row = pd.DataFrame([row_data], columns=df.columns)
+
+ if index == 0:
+ return pd.concat([new_row, df], ignore_index=True)
+ else:
+ before = df.iloc[:index]
+ after = df.iloc[index:]
+ return pd.concat([before, new_row, after], ignore_index=True)
+
+
+@insert_row.register
+def _(df: PlDataFrame, row_data: list, index: int) -> PlDataFrame:
+ import polars as pl
+
+ row_dict = dict(zip(df.columns, row_data))
+ new_row = pl.DataFrame([row_dict])
+
+ if index == 0:
+ return new_row.vstack(df)
+ else:
+ before = df[:index]
+ after = df[index:]
+ return before.vstack(new_row).vstack(after)
+
+
+@insert_row.register
+def _(df: PyArrowTable, row_data: list, index: int) -> PyArrowTable:
+ import pyarrow as pa
+
+ row_dict = dict(zip(df.column_names, row_data))
+ new_row = pa.table([row_dict])
+
+ if index == 0:
+ return pa.concat_tables([new_row, df])
+ else:
+ before = df.slice(0, index)
+ after = df.slice(index)
+ return pa.concat_tables([before, new_row, after])
diff --git a/great_tables/gt.py b/great_tables/gt.py
index e38875950..db227822f 100644
--- a/great_tables/gt.py
+++ b/great_tables/gt.py
@@ -32,7 +32,7 @@
from ._gt_data import GTData
from ._heading import tab_header
from ._helpers import random_id
-from ._modify_rows import row_group_order, tab_stub, with_id, with_locale
+from ._modify_rows import grand_summary_rows, row_group_order, tab_stub, with_id, with_locale
from ._options import (
opt_align_table_header,
opt_all_caps,
@@ -277,6 +277,7 @@ def __init__(
tab_stub = tab_stub
with_id = with_id
with_locale = with_locale
+ grand_summary_rows = grand_summary_rows
save = save
show = show
From ff293028c5d161701dabcae520570953df4fc872 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 12:08:28 -0400
Subject: [PATCH 02/54] set stub and boxhead to include all rows in final table
---
great_tables/_modify_rows.py | 25 +++++++++++++++++--------
1 file changed, 17 insertions(+), 8 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index b25143a0e..6b15e02af 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -218,7 +218,6 @@ def grand_summary_rows(
fns = [fns]
tbl_data = self._tbl_data
-
new_tbl_data = copy_data(tbl_data)
original_column_names = get_column_names(tbl_data)
@@ -234,18 +233,18 @@ def grand_summary_rows(
col_data = to_list(tbl_data[col])
if fn_name == "min":
- new_cell = [str(min(col_data))]
+ new_cell = [min(col_data)]
elif fn_name == "max":
- new_cell = [str(max(col_data))]
+ new_cell = [max(col_data)]
elif fn_name == "mean":
- new_cell = [str(sum(col_data) / len(col_data))]
+ new_cell = [sum(col_data) / len(col_data)]
elif fn_name == "median":
- new_cell = [str(quantiles(col_data, n=2))]
+ new_cell = [quantiles(col_data, n=2)]
else:
# Should never get here
new_cell = ["hi"]
else:
- new_cell = [missing_text]
+ new_cell = [None]
summary_row += new_cell
@@ -257,6 +256,16 @@ def grand_summary_rows(
# else: # top
# new_data = concat_frames(summary_df, tbl_data)
- print(new_tbl_data)
+ self = self._replace(_tbl_data=new_tbl_data)
+
+ _row_group_info = self._boxhead._get_row_group_column()
+ groupname_col = _row_group_info.var if _row_group_info is not None else None
- return self._replace(_tbl_data=new_tbl_data)
+ _row_name_info = self._boxhead._get_stub_column()
+ rowname_col = _row_name_info.var if _row_name_info is not None else None
+
+ stub, boxhead = self._stub._set_cols(self._tbl_data, self._boxhead, rowname_col, groupname_col)
+
+ self._body.body = new_tbl_data
+
+ return self._replace(_stub=stub, _boxhead=boxhead)
From f3a26a15ec60ef280fd9700210e4277f78fc1668 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 15:35:21 -0400
Subject: [PATCH 03/54] add create_no_row_frame and refactor insert_row
---
great_tables/_tbl_data.py | 56 +++++++++++++++++++++++++--------------
1 file changed, 36 insertions(+), 20 deletions(-)
diff --git a/great_tables/_tbl_data.py b/great_tables/_tbl_data.py
index fbe0dea97..cd9377bfc 100644
--- a/great_tables/_tbl_data.py
+++ b/great_tables/_tbl_data.py
@@ -890,13 +890,9 @@ def _(df: PdDataFrame, row_data: list, index: int) -> PdDataFrame:
import pandas as pd
new_row = pd.DataFrame([row_data], columns=df.columns)
-
- if index == 0:
- return pd.concat([new_row, df], ignore_index=True)
- else:
- before = df.iloc[:index]
- after = df.iloc[index:]
- return pd.concat([before, new_row, after], ignore_index=True)
+ before = df.iloc[:index]
+ after = df.iloc[index:]
+ return pd.concat([before, new_row, after], ignore_index=True)
@insert_row.register
@@ -905,13 +901,9 @@ def _(df: PlDataFrame, row_data: list, index: int) -> PlDataFrame:
row_dict = dict(zip(df.columns, row_data))
new_row = pl.DataFrame([row_dict])
-
- if index == 0:
- return new_row.vstack(df)
- else:
- before = df[:index]
- after = df[index:]
- return before.vstack(new_row).vstack(after)
+ before = df[:index]
+ after = df[index:]
+ return before.vstack(new_row).vstack(after)
@insert_row.register
@@ -920,10 +912,34 @@ def _(df: PyArrowTable, row_data: list, index: int) -> PyArrowTable:
row_dict = dict(zip(df.column_names, row_data))
new_row = pa.table([row_dict])
+ before = df.slice(0, index)
+ after = df.slice(index)
+ return pa.concat_tables([before, new_row, after])
- if index == 0:
- return pa.concat_tables([new_row, df])
- else:
- before = df.slice(0, index)
- after = df.slice(index)
- return pa.concat_tables([before, new_row, after])
+
+# create_no_row_frame ----
+
+
+@singledispatch
+def create_no_row_frame(df: DataFrameLike) -> DataFrameLike:
+ """Return a DataFrame with the same columns but no rows"""
+ raise NotImplementedError(f"Unsupported type: {type(df)}")
+
+
+@create_no_row_frame.register
+def _(df: PdDataFrame):
+ import pandas as pd
+
+ return pd.DataFrame(columns=df.columns).astype(df.dtypes)
+
+
+@create_no_row_frame.register
+def _(df: PlDataFrame):
+ return df.clear()
+
+
+@create_no_row_frame.register
+def _(df: PyArrowTable):
+ import pyarrow as pa
+
+ return pa.table({col: pa.array([], type=df.column(col).type) for col in df.column_names})
From c810a6a54155d75b3fb4d81b883118c3ca6cb2ef Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 15:35:40 -0400
Subject: [PATCH 04/54] add SummaryRows to GTData object
---
great_tables/_gt_data.py | 28 +++++++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index a5603f523..81d8be1f8 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -84,6 +84,7 @@ class GTData:
_options: Options
_google_font_imports: GoogleFontImports = field(default_factory=GoogleFontImports)
_has_built: bool = False
+ _summary_rows: SummaryRows | None = None
def _replace(self, **kwargs: Any) -> Self:
new_obj = copy.copy(self)
@@ -719,7 +720,7 @@ class GroupRowInfo:
indices: list[int] = field(default_factory=list)
# row_start: int | None = None
# row_end: int | None = None
- has_summary_rows: bool = False
+ has_summary_rows: bool = False # TODO: remove
summary_row_side: str | None = None
def defaulted_label(self) -> str:
@@ -972,6 +973,31 @@ def __init__(self, func: FormatFns, cols: list[str], rows: list[int]):
Formats = list
+# Summary Rows ---
+GRAND_SUMMARY_GROUP = GroupRowInfo(group_id="__grand_summary_group__")
+
+
+@dataclass(frozen=True)
+class SummaryRowInfo:
+ """Information about a single summary row"""
+
+ function: Literal["min", "max", "mean", "median"]
+ values: TblData
+ side: Literal["top", "bottom"]
+ group: GroupRowInfo
+
+
+class SummaryRows(_Sequence[SummaryRowInfo]):
+ """A sequence of summary rows"""
+
+ _d: list[SummaryRowInfo]
+
+ def __init__(self, rows: list[SummaryRowInfo] | None = None):
+ if rows is None:
+ rows = []
+ self._d = rows
+
+
# Options ----
default_fonts_list = [
From 21996ad35677e5a586db3f3d0159207f158ac820 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 15:36:01 -0400
Subject: [PATCH 05/54] refactor modify rows
---
great_tables/_modify_rows.py | 192 ++++++++++++++++++++++++-----------
1 file changed, 135 insertions(+), 57 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 6b15e02af..f26391c52 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -1,14 +1,22 @@
from __future__ import annotations
from statistics import quantiles
-from typing import TYPE_CHECKING, Literal
+from typing import TYPE_CHECKING, Any, Literal
from great_tables._locations import resolve_cols_c
-from ._gt_data import Locale, RowGroups, Styles
+from ._gt_data import (
+ GRAND_SUMMARY_GROUP,
+ GTData,
+ Locale,
+ RowGroups,
+ Styles,
+ SummaryRowInfo,
+ SummaryRows,
+)
from ._tbl_data import (
SelectExpr,
- copy_data,
+ create_no_row_frame,
get_column_names,
insert_row,
to_list,
@@ -190,82 +198,152 @@ def with_id(self: GTSelf, id: str | None = None) -> GTSelf:
return self._replace(_options=self._options._set_option_value("table_id", id))
+def grand_summary_rows(
+ self: GTSelf,
+ fns: list[Literal["min", "max", "mean", "median"]] | Literal["min", "max", "mean", "median"],
+ columns: SelectExpr = None,
+ side: Literal["bottom", "top"] = "bottom",
+ missing_text: str = "---",
+) -> GTSelf:
+ """Add grand summary rows to the table.
+
+ Computes summary rows immediately but stores them separately from main data.
+ """
+
+ if isinstance(fns, str):
+ fns = [fns]
+
+ # Compute summary rows immediately
+ summary_row_infos = []
+ for fn_name in fns:
+ row_values_list = _calculate_summary_row(
+ self, fn_name, columns, missing_text, group_id=None
+ )
+
+ # Convert list of values to TblData (single row DataFrame)
+ summary_tbl_data = create_no_row_frame(self._tbl_data)
+ summary_tbl_data = insert_row(summary_tbl_data, row_values_list, len(summary_tbl_data))
+
+ summary_row_info = SummaryRowInfo(
+ function=fn_name,
+ values=summary_tbl_data,
+ side=side,
+ group=GRAND_SUMMARY_GROUP,
+ )
+ summary_row_infos.append(summary_row_info) # There is probably a better way to do this
+
+ existing_rows = self._summary_rows._d if self._summary_rows is not None else []
+ new_summary_rows = SummaryRows(existing_rows + summary_row_infos)
+
+ print([n.values for n in new_summary_rows])
+
+ return self._replace(_summary_rows=new_summary_rows)
+
+
# def grand_summary_rows(
# self: GTSelf,
# fns: list[Literal["min", "max", "mean", "median"]] | Literal["min", "max", "mean", "median"],
# columns: SelectExpr = None,
# side: Literal["bottom", "top"] = "bottom",
+# missing_text: str = "---",
# ) -> GTSelf:
-# new_body = self._body.copy()
-# new_tbl_data = new_body.body
+# if isinstance(fns, str):
+# fns = [fns]
+# tbl_data = self._tbl_data
+# new_tbl_data = copy_data(tbl_data)
-# new_body.append()
+# original_column_names = get_column_names(tbl_data)
-# self._replace(_body=new_body)
+# summary_col_names = resolve_cols_c(data=self, expr=columns)
-# return self
+# # Create summary rows DataFrame
+# for fn_name in fns:
+# summary_row = []
+# for col in original_column_names:
+# if col in summary_col_names:
+# col_data = to_list(tbl_data[col])
-def grand_summary_rows(
- self: GTSelf,
- fns: list[Literal["min", "max", "mean", "median"]] | Literal["min", "max", "mean", "median"],
- columns: SelectExpr = None,
- side: Literal["bottom", "top"] = "bottom",
- missing_text: str = "---",
-) -> GTSelf:
- if isinstance(fns, str):
- fns = [fns]
+# if fn_name == "min":
+# new_cell = [min(col_data)]
+# elif fn_name == "max":
+# new_cell = [max(col_data)]
+# elif fn_name == "mean":
+# new_cell = [sum(col_data) / len(col_data)]
+# elif fn_name == "median":
+# new_cell = [quantiles(col_data, n=2)]
+# else:
+# # Should never get here
+# new_cell = ["hi"]
+# else:
+# new_cell = [None]
- tbl_data = self._tbl_data
- new_tbl_data = copy_data(tbl_data)
+# summary_row += new_cell
- original_column_names = get_column_names(tbl_data)
+# new_tbl_data = insert_row(new_tbl_data, summary_row, 0)
- summary_col_names = resolve_cols_c(data=self, expr=columns)
+# # Concatenate based on side parameter
+# # if side == "bottom":
+# # new_data = concat_frames(tbl_data, summary_df)
+# # else: # top
+# # new_data = concat_frames(summary_df, tbl_data)
- # Create summary rows DataFrame
- for fn_name in fns:
- summary_row = []
-
- for col in original_column_names:
- if col in summary_col_names:
- col_data = to_list(tbl_data[col])
-
- if fn_name == "min":
- new_cell = [min(col_data)]
- elif fn_name == "max":
- new_cell = [max(col_data)]
- elif fn_name == "mean":
- new_cell = [sum(col_data) / len(col_data)]
- elif fn_name == "median":
- new_cell = [quantiles(col_data, n=2)]
- else:
- # Should never get here
- new_cell = ["hi"]
- else:
- new_cell = [None]
+# self = self._replace(_tbl_data=new_tbl_data)
- summary_row += new_cell
+# _row_group_info = self._boxhead._get_row_group_column()
+# groupname_col = _row_group_info.var if _row_group_info is not None else None
- new_tbl_data = insert_row(new_tbl_data, summary_row, 0)
+# _row_name_info = self._boxhead._get_stub_column()
+# rowname_col = _row_name_info.var if _row_name_info is not None else None
- # Concatenate based on side parameter
- # if side == "bottom":
- # new_data = concat_frames(tbl_data, summary_df)
- # else: # top
- # new_data = concat_frames(summary_df, tbl_data)
+# stub, boxhead = self._stub._set_cols(self._tbl_data, self._boxhead, rowname_col, groupname_col)
- self = self._replace(_tbl_data=new_tbl_data)
+# self._body.body = new_tbl_data
- _row_group_info = self._boxhead._get_row_group_column()
- groupname_col = _row_group_info.var if _row_group_info is not None else None
+# return self._replace(_stub=stub, _boxhead=boxhead)
- _row_name_info = self._boxhead._get_stub_column()
- rowname_col = _row_name_info.var if _row_name_info is not None else None
- stub, boxhead = self._stub._set_cols(self._tbl_data, self._boxhead, rowname_col, groupname_col)
+def _calculate_summary_row(
+ data: GTData,
+ fn_name: str,
+ columns: SelectExpr,
+ missing_text: str,
+ group_id: str | None = None, # None means grand summary (all data)
+) -> list[Any]:
+ """Calculate a summary row based on the function name and selected columns for a specific group."""
+ tbl_data = data._tbl_data
- self._body.body = new_tbl_data
+ original_column_names = get_column_names(tbl_data)
- return self._replace(_stub=stub, _boxhead=boxhead)
+ summary_col_names = resolve_cols_c(data=data, expr=columns)
+
+ if group_id is None:
+ group_id = GRAND_SUMMARY_GROUP.group_id
+ else:
+ # Future: group-specific logic would go here
+ raise NotImplementedError("Group-specific summaries not yet implemented")
+
+ # Create summary rows _tbl_data
+ summary_row = []
+
+ for col in original_column_names:
+ if col in summary_col_names:
+ col_data = to_list(tbl_data[col])
+
+ if fn_name == "min":
+ new_cell = [min(col_data)]
+ elif fn_name == "max":
+ new_cell = [max(col_data)]
+ elif fn_name == "mean":
+ new_cell = [sum(col_data) / len(col_data)]
+ elif fn_name == "median":
+ new_cell = [quantiles(col_data, n=2)]
+ else:
+ # Should never get here
+ new_cell = ["hi"]
+ else:
+ new_cell = [missing_text]
+
+ summary_row += new_cell
+ return summary_row
From aaa887ccfc3b2532f816027eb203ecf553db1e3b Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 16:57:59 -0400
Subject: [PATCH 06/54] add merge_summary_rows to append to body
---
great_tables/_gt_data.py | 34 +++++++++++++++++++++++++++++++++-
1 file changed, 33 insertions(+), 1 deletion(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 81d8be1f8..968552d7d 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -24,6 +24,7 @@
copy_data,
create_empty_frame,
get_column_names,
+ insert_row,
n_rows,
to_list,
validate_frame,
@@ -180,6 +181,37 @@ class Body:
def __init__(self, body: TblData):
self.body = body
+ def merge_summary_rows(self, old_tbl_data: TblData, summary_rows: SummaryRows):
+ if not summary_rows or summary_rows == []:
+ return self
+
+ tbl_data = self.body
+
+ for i, summary_row in enumerate(summary_rows):
+ # Concatenate based on side parameter
+ if summary_row.side == "bottom":
+ tbl_data = insert_row(tbl_data, summary_row.values, n_rows(tbl_data))
+ else: # top
+ tbl_data = insert_row(tbl_data, summary_row.values, i)
+
+ self.body = tbl_data
+
+ # _row_group_info = self._boxhead._get_row_group_column()
+ # groupname_col = _row_group_info.var if _row_group_info is not None else None
+
+ # _row_name_info = self._boxhead._get_stub_column()
+ # rowname_col = _row_name_info.var if _row_name_info is not None else None
+
+ # stub, boxhead = self._stub._set_cols(
+ # self._tbl_data, self._boxhead, rowname_col, groupname_col
+ # )
+
+ # self._body.body = new_tbl_data
+
+ # return self._replace(_stub=stub, _boxhead=boxhead)
+
+ return self
+
def render_formats(self, data_tbl: TblData, formats: list[FormatInfo], context: Any):
for fmt in formats:
eval_func = getattr(fmt.func, context, fmt.func.default)
@@ -982,7 +1014,7 @@ class SummaryRowInfo:
"""Information about a single summary row"""
function: Literal["min", "max", "mean", "median"]
- values: TblData
+ values: list[str | int | float] # TODO: consider datatype
side: Literal["top", "bottom"]
group: GroupRowInfo
From 6019d7e1ba67d16701b9699be5d501189bf16980 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 16:58:30 -0400
Subject: [PATCH 07/54] replace len with n_rows
---
great_tables/_modify_rows.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index f26391c52..0462fd2ff 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -19,6 +19,7 @@
create_no_row_frame,
get_column_names,
insert_row,
+ n_rows,
to_list,
)
@@ -222,11 +223,11 @@ def grand_summary_rows(
# Convert list of values to TblData (single row DataFrame)
summary_tbl_data = create_no_row_frame(self._tbl_data)
- summary_tbl_data = insert_row(summary_tbl_data, row_values_list, len(summary_tbl_data))
+ summary_tbl_data = insert_row(summary_tbl_data, row_values_list, n_rows(summary_tbl_data))
summary_row_info = SummaryRowInfo(
function=fn_name,
- values=summary_tbl_data,
+ values=row_values_list, # TODO: revisit type
side=side,
group=GRAND_SUMMARY_GROUP,
)
@@ -235,8 +236,6 @@ def grand_summary_rows(
existing_rows = self._summary_rows._d if self._summary_rows is not None else []
new_summary_rows = SummaryRows(existing_rows + summary_row_infos)
- print([n.values for n in new_summary_rows])
-
return self._replace(_summary_rows=new_summary_rows)
From 0e8536390cc499ae6e50ca55664689b75ce4fcd5 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 16:58:42 -0400
Subject: [PATCH 08/54] update build step
---
great_tables/gt.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/great_tables/gt.py b/great_tables/gt.py
index db227822f..b9975cf3f 100644
--- a/great_tables/gt.py
+++ b/great_tables/gt.py
@@ -315,6 +315,12 @@ def _render_formats(self, context: str) -> Self:
return self._replace(_body=new_body)
def _build_data(self, context: str) -> Self:
+ new_body = self._body.copy()
+
+ # mutation
+ new_body.merge_summary_rows(self._tbl_data, self._summary_rows)
+ self = self._replace(_body=new_body)
+
# Build the body of the table by generating a dictionary
# of lists with cells initially set to nan values
built = self._render_formats(context)
From 059686ff957a219415e3f7e2a6d88e0ed5bcb7f2 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 13 Aug 2025 17:47:22 -0400
Subject: [PATCH 09/54] add comments mapping out rendering plan
---
great_tables/_gt_data.py | 39 +++++++++---------------------
great_tables/_modify_rows.py | 5 +++-
great_tables/_utils_render_html.py | 5 ++++
great_tables/gt.py | 8 +++---
4 files changed, 25 insertions(+), 32 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 968552d7d..038b1e5ed 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -24,7 +24,6 @@
copy_data,
create_empty_frame,
get_column_names,
- insert_row,
n_rows,
to_list,
validate_frame,
@@ -181,36 +180,22 @@ class Body:
def __init__(self, body: TblData):
self.body = body
- def merge_summary_rows(self, old_tbl_data: TblData, summary_rows: SummaryRows):
- if not summary_rows or summary_rows == []:
- return self
-
- tbl_data = self.body
-
- for i, summary_row in enumerate(summary_rows):
- # Concatenate based on side parameter
- if summary_row.side == "bottom":
- tbl_data = insert_row(tbl_data, summary_row.values, n_rows(tbl_data))
- else: # top
- tbl_data = insert_row(tbl_data, summary_row.values, i)
-
- self.body = tbl_data
+ # def merge_summary_rows(self, old_tbl_data: TblData, summary_rows: SummaryRows):
+ # if not summary_rows or summary_rows == []:
+ # return self
- # _row_group_info = self._boxhead._get_row_group_column()
- # groupname_col = _row_group_info.var if _row_group_info is not None else None
+ # tbl_data = self.body
- # _row_name_info = self._boxhead._get_stub_column()
- # rowname_col = _row_name_info.var if _row_name_info is not None else None
+ # for i, summary_row in enumerate(summary_rows):
+ # # Concatenate based on side parameter
+ # if summary_row.side == "bottom":
+ # tbl_data = insert_row(tbl_data, summary_row.values, n_rows(tbl_data))
+ # else: # top
+ # tbl_data = insert_row(tbl_data, summary_row.values, i)
- # stub, boxhead = self._stub._set_cols(
- # self._tbl_data, self._boxhead, rowname_col, groupname_col
- # )
+ # self.body = tbl_data
- # self._body.body = new_tbl_data
-
- # return self._replace(_stub=stub, _boxhead=boxhead)
-
- return self
+ # return self
def render_formats(self, data_tbl: TblData, formats: list[FormatInfo], context: Any):
for fmt in formats:
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 0462fd2ff..f09c30d1d 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -208,8 +208,9 @@ def grand_summary_rows(
) -> GTSelf:
"""Add grand summary rows to the table.
- Computes summary rows immediately but stores them separately from main data.
+ TODO docstring
"""
+ # Computes summary rows immediately but stores them separately from main data.
if isinstance(fns, str):
fns = [fns]
@@ -221,6 +222,8 @@ def grand_summary_rows(
self, fn_name, columns, missing_text, group_id=None
)
+ # TODO: minimize to one new df function, don't need insert row elsewhere.
+ # Maybe don't even need this to be a SeriesLike or DataFrameLike
# Convert list of values to TblData (single row DataFrame)
summary_tbl_data = create_no_row_frame(self._tbl_data)
summary_tbl_data = insert_row(summary_tbl_data, row_values_list, n_rows(summary_tbl_data))
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 3672d9464..07110f2e4 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -549,6 +549,11 @@ def create_body_component_h(data: GTData) -> str:
body_rows.append("
\n" + "\n".join(body_cells) + "\n
")
+ ## after the last row in the group, we need to append the summary rows for the group
+ ## if this table has summary rows
+
+ ## outside of the standard body rows loop, we need to add summary rows that are grand here
+
all_body_rows = "\n".join(body_rows)
return f"""
diff --git a/great_tables/gt.py b/great_tables/gt.py
index b9975cf3f..a44a179aa 100644
--- a/great_tables/gt.py
+++ b/great_tables/gt.py
@@ -315,11 +315,11 @@ def _render_formats(self, context: str) -> Self:
return self._replace(_body=new_body)
def _build_data(self, context: str) -> Self:
- new_body = self._body.copy()
+ # new_body = self._body.copy()
- # mutation
- new_body.merge_summary_rows(self._tbl_data, self._summary_rows)
- self = self._replace(_body=new_body)
+ # # mutation
+ # new_body.merge_summary_rows(self._tbl_data, self._summary_rows)
+ # self = self._replace(_body=new_body)
# Build the body of the table by generating a dictionary
# of lists with cells initially set to nan values
From cd6299e020719ea82e668e12152fe75719d22d5f Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 10:48:20 -0400
Subject: [PATCH 10/54] new locations for grand summary (and summary)
---
great_tables/_locations.py | 58 ++++++++++++++++++++++++++++++++++----
great_tables/loc.py | 10 +++++++
2 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 246966303..95f9ae996 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -481,7 +481,12 @@ class LocRowGroups(Loc):
@dataclass
-class LocSummaryLabel(Loc):
+class LocSummaryStub(Loc):
+ rows: RowSelectExpr = None
+
+
+@dataclass
+class LocGrandSummaryStub(Loc):
rows: RowSelectExpr = None
@@ -557,6 +562,13 @@ class LocSummary(Loc):
rows: RowSelectExpr = None
+@dataclass
+class LocGrandSummary(Loc):
+ # TODO: these can be tidyselectors
+ columns: SelectExpr = None
+ rows: RowSelectExpr = None
+
+
@dataclass
class LocFooter(Loc):
"""Target the table footer.
@@ -918,6 +930,16 @@ def _(loc: LocStub, data: GTData) -> set[int]:
return cell_pos
+@resolve.register
+def _(loc: LocSummaryStub, data: GTData) -> set[int]:
+ pass
+
+
+@resolve.register
+def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
+ pass
+
+
@resolve.register
def _(loc: LocBody, data: GTData) -> list[CellPos]:
if (loc.columns is not None or loc.rows is not None) and loc.mask is not None:
@@ -939,6 +961,16 @@ def _(loc: LocBody, data: GTData) -> list[CellPos]:
return cell_pos
+# @resolve.register
+# def _(loc: LocSummary, data: GTData) -> list[CellPos]:
+# pass
+
+
+@resolve.register
+def _(loc: LocGrandSummary, data: GTData) -> list[CellPos]:
+ pass
+
+
# Style generic ========================================================================
@@ -953,9 +985,11 @@ def _(loc: LocBody, data: GTData) -> list[CellPos]:
# LocStub
# LocRowGroupLabel
# LocRowLabel
-# LocSummaryLabel
+# LocSummaryStub
+# LocGrandSummaryStub
# LocBody
# LocSummary
+# LocGrandSummary
# LocFooter
# LocFootnotes
# LocSourceNotes
@@ -1039,8 +1073,14 @@ def _(loc: LocRowGroups, data: GTData, style: list[CellStyle]) -> GTData:
)
-@set_style.register
-def _(loc: LocStub, data: GTData, style: list[CellStyle]) -> GTData:
+@set_style.register(LocStub)
+@set_style.register(LocSummaryStub)
+@set_style.register(LocGrandSummaryStub)
+def _(
+ loc: (LocStub | LocSummaryStub | LocGrandSummaryStub),
+ data: GTData,
+ style: list[CellStyle],
+) -> GTData:
# validate ----
for entry in style:
entry._raise_if_requires_data(loc)
@@ -1051,8 +1091,14 @@ def _(loc: LocStub, data: GTData, style: list[CellStyle]) -> GTData:
return data._replace(_styles=data._styles + new_styles)
-@set_style.register
-def _(loc: LocBody, data: GTData, style: list[CellStyle]) -> GTData:
+@set_style.register(LocBody)
+@set_style.register(LocSummary)
+@set_style.register(LocGrandSummary)
+def _(
+ loc: (LocBody | LocSummary | LocGrandSummary),
+ data: GTData,
+ style: list[CellStyle],
+) -> GTData:
positions: list[CellPos] = resolve(loc, data)
# evaluate any column expressions in styles
diff --git a/great_tables/loc.py b/great_tables/loc.py
index e463ab132..86df0ca5e 100644
--- a/great_tables/loc.py
+++ b/great_tables/loc.py
@@ -17,10 +17,16 @@
# Stub ----
LocStub as stub,
LocRowGroups as row_groups,
+ # LocSummaryStub as summary_stub,
+ LocGrandSummaryStub as grand_summary_stub,
#
# Body ----
LocBody as body,
#
+ # Summary ----
+ # LocSummary as summary,
+ LocGrandSummary as grand_summary,
+ #
# Footer ----
LocFooter as footer,
LocSourceNotes as source_notes,
@@ -36,7 +42,11 @@
"column_labels",
"stub",
"row_groups",
+ # "summary_stub",
+ "grand_summary_stub",
"body",
+ # "summary",
+ "grand_summary",
"footer",
"source_notes",
)
From 7e1985adc81315667fd13a839a6c5f7612f5e68b Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 10:52:19 -0400
Subject: [PATCH 11/54] condensing locations
---
great_tables/_locations.py | 32 ++++++++------------------------
1 file changed, 8 insertions(+), 24 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 95f9ae996..d4355f12f 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -922,26 +922,20 @@ def _(loc: LocRowGroups, data: GTData) -> set[str]:
return group_pos
-@resolve.register
-def _(loc: LocStub, data: GTData) -> set[int]:
+@resolve.register(LocStub)
+@resolve.register(LocSummaryStub)
+@resolve.register(LocGrandSummaryStub)
+def _(loc: (LocStub | LocSummaryStub | LocGrandSummaryStub), data: GTData) -> set[int]:
# TODO: what are the rules for matching row groups?
rows = resolve_rows_i(data=data, expr=loc.rows)
cell_pos = set(row[1] for row in rows)
return cell_pos
-@resolve.register
-def _(loc: LocSummaryStub, data: GTData) -> set[int]:
- pass
-
-
-@resolve.register
-def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
- pass
-
-
-@resolve.register
-def _(loc: LocBody, data: GTData) -> list[CellPos]:
+@resolve.register(LocBody)
+@resolve.register(LocSummary)
+@resolve.register(LocGrandSummary)
+def _(loc: (LocBody | LocSummary | LocGrandSummary), data: GTData) -> list[CellPos]:
if (loc.columns is not None or loc.rows is not None) and loc.mask is not None:
raise ValueError(
"Cannot specify the `mask` argument along with `columns` or `rows` in `loc.body()`."
@@ -961,16 +955,6 @@ def _(loc: LocBody, data: GTData) -> list[CellPos]:
return cell_pos
-# @resolve.register
-# def _(loc: LocSummary, data: GTData) -> list[CellPos]:
-# pass
-
-
-@resolve.register
-def _(loc: LocGrandSummary, data: GTData) -> list[CellPos]:
- pass
-
-
# Style generic ========================================================================
From 07d3574735da1a191ae22a7a9940465dd2d5993e Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 13:40:32 -0400
Subject: [PATCH 12/54] add getter for summary rows
---
great_tables/_gt_data.py | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 038b1e5ed..5d1e233f0 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -75,6 +75,7 @@ class GTData:
_spanners: Spanners
_heading: Heading
_stubhead: Stubhead
+ _summary_rows: SummaryRows
_source_notes: SourceNotes
_footnotes: Footnotes
_styles: Styles
@@ -84,7 +85,6 @@ class GTData:
_options: Options
_google_font_imports: GoogleFontImports = field(default_factory=GoogleFontImports)
_has_built: bool = False
- _summary_rows: SummaryRows | None = None
def _replace(self, **kwargs: Any) -> Self:
new_obj = copy.copy(self)
@@ -123,6 +123,7 @@ def from_data(
_spanners=Spanners([]),
_heading=Heading(),
_stubhead=None,
+ _summary_rows=SummaryRows([]),
_source_notes=[],
_footnotes=[],
_styles=[],
@@ -1014,6 +1015,16 @@ def __init__(self, rows: list[SummaryRowInfo] | None = None):
rows = []
self._d = rows
+ def summary_rows_dict(self) -> dict[str, list[SummaryRowInfo]]:
+ """Get dictionary mapping group_id to list of summary rows for that group"""
+ result = {}
+ for summary_row in self._d:
+ group_id = summary_row.group.group_id
+ if group_id not in result:
+ result[group_id] = []
+ result[group_id].append(summary_row)
+ return result
+
# Options ----
From 1acbabd33b0cfced5f90639ed9e61c10dd592f6d Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 13:40:51 -0400
Subject: [PATCH 13/54] begin design on rendering top grand summary rows
---
great_tables/_utils_render_html.py | 32 ++++++++++++++++++++++++++++--
1 file changed, 30 insertions(+), 2 deletions(-)
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 07110f2e4..7bc59709f 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -6,7 +6,7 @@
from htmltools import HTML, TagList, css, tags
from . import _locations as loc
-from ._gt_data import GroupRowInfo, GTData, Styles
+from ._gt_data import GRAND_SUMMARY_GROUP, GroupRowInfo, GTData, Styles
from ._spanners import spanners_print_matrix
from ._tbl_data import _get_cell, cast_frame_to_string, replace_null_frame
from ._text import BaseText, _process_text, _process_text_id
@@ -426,12 +426,16 @@ def create_body_component_h(data: GTData) -> str:
# Filter list of StyleInfo to only those that apply to the stub
styles_row_group_label = [x for x in data._styles if _is_loc(x.locname, loc.LocRowGroups)]
styles_row_label = [x for x in data._styles if _is_loc(x.locname, loc.LocStub)]
- # styles_summary_label = [x for x in data._styles if _is_loc(x.locname, loc.LocSummaryLabel)]
+ # styles_summary_label = [x for x in data._styles if _is_loc(x.locname, loc.LocSummaryStub)]
+ styles_grand_summary_label = [
+ x for x in data._styles if _is_loc(x.locname, loc.LocGrandSummaryStub)
+ ]
# Filter list of StyleInfo to only those that apply to the body
styles_cells = [x for x in data._styles if _is_loc(x.locname, loc.LocBody)]
# styles_body = [x for x in data._styles if _is_loc(x.locname, loc.LocBody2)]
# styles_summary = [x for x in data._styles if _is_loc(x.locname, loc.LocSummary)]
+ styles_grand_summary = [x for x in data._styles if _is_loc(x.locname, loc.LocGrandSummary)]
# Get the default column vars
column_vars = data._boxhead._get_default_columns()
@@ -456,6 +460,30 @@ def create_body_component_h(data: GTData) -> str:
body_rows: list[str] = []
+ # Load summary rows
+ summary_rows = data._summary_rows.summary_rows_dict()
+
+ # Add grand summary rows if there are summary rows in GRAND_SUMMARY_GROUP at top
+ grand_summary_rows = summary_rows.get(GRAND_SUMMARY_GROUP.group_id)
+ if grand_summary_rows:
+ print(grand_summary_rows)
+ # Filter for rows with side == "top"
+ # for row in grand_summary_rows:
+ # if row.side == "top":
+
+ # label = row.values
+ # _styles = [
+ # style
+ # for style in styles_row_group_label
+ # if GRAND_SUMMARY_GROUP.group_id in getattr(style, "grpname", [])
+ # ]
+ # group_styles = _flatten_styles(_styles, wrap=True)
+ # body_rows.append(
+ # f"""
+ # {group_label} |
+ #
"""
+ # )
+
# iterate over rows (ordered by groupings)
prev_group_info = None
From afae957795c4889cd880f3062ee8e6b22910439b Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 14:18:18 -0400
Subject: [PATCH 14/54] refactor _create_row_component_h(), outside of
create_body_component_h(), called also for making summary rows
---
great_tables/_utils_render_html.py | 196 ++++++++++++++++++-----------
1 file changed, 122 insertions(+), 74 deletions(-)
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 7bc59709f..20c3abf6a 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -6,9 +6,17 @@
from htmltools import HTML, TagList, css, tags
from . import _locations as loc
-from ._gt_data import GRAND_SUMMARY_GROUP, GroupRowInfo, GTData, Styles
+from ._gt_data import (
+ GRAND_SUMMARY_GROUP,
+ ColInfo,
+ GroupRowInfo,
+ GTData,
+ StyleInfo,
+ Styles,
+ SummaryRowInfo,
+)
from ._spanners import spanners_print_matrix
-from ._tbl_data import _get_cell, cast_frame_to_string, replace_null_frame
+from ._tbl_data import TblData, _get_cell, cast_frame_to_string, replace_null_frame
from ._text import BaseText, _process_text, _process_text_id
from ._utils import heading_has_subtitle, heading_has_title, seq_groups
@@ -417,6 +425,86 @@ def create_columns_component_h(data: GTData) -> str:
return table_col_headings
+def _create_row_component_h(
+ column_vars: list[ColInfo],
+ stub_var: ColInfo | None,
+ has_stub_column: bool,
+ apply_stub_striping: bool,
+ apply_body_striping: bool,
+ styles_cells: list[StyleInfo], # Either styles_cells OR styles_grand_summary
+ styles_labels: list[StyleInfo], # Either styles_row_label OR styles_grand_summary_label
+ row_index: int | None = None, # For data rows
+ summary_row: SummaryRowInfo | None = None, # For summary rows
+ tbl_data: TblData | None = None,
+) -> str:
+ """Create a single table row (either data row or summary row)"""
+
+ is_summary_row = summary_row is not None
+ body_cells: list[str] = []
+
+ for colinfo in column_vars:
+ # Get cell content
+ if is_summary_row:
+ if colinfo == stub_var:
+ cell_content = summary_row.function.capitalize()
+ else:
+ non_stub_cols = [col for col in column_vars if col != stub_var]
+ try:
+ col_index = non_stub_cols.index(colinfo)
+ cell_content = (
+ summary_row.values[col_index]
+ if col_index < len(summary_row.values)
+ else "---"
+ )
+ except ValueError:
+ cell_content = "---"
+ else:
+ cell_content = _get_cell(tbl_data, row_index, colinfo.var)
+
+ cell_str = str(cell_content)
+ is_stub_cell = has_stub_column and colinfo.var == stub_var.var
+ cell_alignment = colinfo.defaulted_align
+
+ # Get styles
+ if is_summary_row:
+ _body_styles = styles_cells # Already filtered to grand summary styles
+ _rowname_styles = (
+ styles_labels if is_stub_cell else []
+ ) # Already filtered to grand summary label styles
+ else:
+ _body_styles = [
+ x for x in styles_cells if x.rownum == row_index and x.colname == colinfo.var
+ ]
+ _rowname_styles = (
+ [x for x in styles_labels if x.rownum == row_index] if is_stub_cell else []
+ )
+
+ # Build classes and element
+ if is_stub_cell:
+ el_name = "th"
+ classes = ["gt_row", "gt_left", "gt_stub"]
+ if is_summary_row:
+ classes.append("gt_grand_summary")
+ if apply_stub_striping:
+ classes.append("gt_striped")
+ else:
+ el_name = "td"
+ classes = ["gt_row", f"gt_{cell_alignment}"]
+ if is_summary_row:
+ classes.append("gt_grand_summary")
+ if apply_body_striping:
+ classes.append("gt_striped")
+
+ classes_str = " ".join(classes)
+ cell_styles = _flatten_styles(_body_styles + _rowname_styles, wrap=True)
+
+ body_cells.append(
+ f""" <{el_name}{cell_styles} class="{classes_str}">{cell_str}{el_name}>"""
+ )
+
+ return " \n" + "\n".join(body_cells) + "\n
"
+
+
def create_body_component_h(data: GTData) -> str:
# for now, just coerce everything in the original data to a string
# so we can fill in the body data with it
@@ -461,28 +549,23 @@ def create_body_component_h(data: GTData) -> str:
body_rows: list[str] = []
# Load summary rows
- summary_rows = data._summary_rows.summary_rows_dict()
-
- # Add grand summary rows if there are summary rows in GRAND_SUMMARY_GROUP at top
- grand_summary_rows = summary_rows.get(GRAND_SUMMARY_GROUP.group_id)
- if grand_summary_rows:
- print(grand_summary_rows)
- # Filter for rows with side == "top"
- # for row in grand_summary_rows:
- # if row.side == "top":
-
- # label = row.values
- # _styles = [
- # style
- # for style in styles_row_group_label
- # if GRAND_SUMMARY_GROUP.group_id in getattr(style, "grpname", [])
- # ]
- # group_styles = _flatten_styles(_styles, wrap=True)
- # body_rows.append(
- # f"""
- # {group_label} |
- #
"""
- # )
+ summary_rows_dict = data._summary_rows.summary_rows_dict()
+
+ # Add grand summary rows at top (no striping for summary rows)
+ grand_summary_rows = summary_rows_dict.get(GRAND_SUMMARY_GROUP.group_id, [])
+ for summary_row in grand_summary_rows:
+ if summary_row.side == "top":
+ row_html = _create_row_component_h(
+ column_vars=column_vars,
+ stub_var=stub_var,
+ has_stub_column=has_stub_column,
+ apply_stub_striping=False, # No striping for summary rows
+ apply_body_striping=False, # No striping for summary rows
+ styles_cells=styles_grand_summary,
+ styles_labels=styles_grand_summary_label,
+ summary_row=summary_row,
+ )
+ body_rows.append(row_html)
# iterate over rows (ordered by groupings)
prev_group_info = None
@@ -523,59 +606,24 @@ def create_body_component_h(data: GTData) -> str:
body_rows.append(group_row)
- # Create row cells
- for colinfo in column_vars:
- cell_content: Any = _get_cell(tbl_data, i, colinfo.var)
- cell_str: str = str(cell_content)
-
- # Determine whether the current cell is the stub cell
- if has_stub_column:
- is_stub_cell = colinfo.var == stub_var.var
- else:
- is_stub_cell = False
-
- # Get alignment for the current column from the `col_alignment` list
- # by using the `name` value to obtain the index of the alignment value
- cell_alignment = colinfo.defaulted_align
-
- # Get the style attributes for the current cell by filtering the
- # `styles_cells` list for the current row and column
- _body_styles = [x for x in styles_cells if x.rownum == i and x.colname == colinfo.var]
-
- if is_stub_cell:
- el_name = "th"
-
- classes = ["gt_row", "gt_left", "gt_stub"]
-
- _rowname_styles = [x for x in styles_row_label if x.rownum == i]
-
- if table_stub_striped and odd_j_row:
- classes.append("gt_striped")
-
- else:
- el_name = "td"
-
- classes = ["gt_row", f"gt_{cell_alignment}"]
-
- _rowname_styles = []
-
- if table_body_striped and odd_j_row:
- classes.append("gt_striped")
-
- # Ensure that `classes` becomes a space-separated string
- classes = " ".join(classes)
- cell_styles = _flatten_styles(
- _body_styles + _rowname_styles,
- wrap=True,
- )
-
- body_cells.append(
- f""" <{el_name}{cell_styles} class="{classes}">{cell_str}{el_name}>"""
- )
+ # Create data row
+ row_html = _create_row_component_h(
+ column_vars=column_vars,
+ stub_var=stub_var,
+ has_stub_column=has_stub_column,
+ apply_stub_striping=table_stub_striped and odd_j_row,
+ apply_body_striping=table_body_striped and odd_j_row,
+ styles_cells=styles_cells,
+ styles_labels=styles_row_label,
+ row_index=i,
+ tbl_data=tbl_data,
+ )
+ body_rows.append(row_html)
prev_group_info = group_info
- body_rows.append(" \n" + "\n".join(body_cells) + "\n
")
+ # unused code
+ # body_rows.append(" \n" + "\n".join(body_cells) + "\n
")
## after the last row in the group, we need to append the summary rows for the group
## if this table has summary rows
From dcbf84cc643d3ff8c8aecc079092b0cb33a631a5 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 14:19:34 -0400
Subject: [PATCH 15/54] remove merge_summary_rows
---
great_tables/_gt_data.py | 17 -----------------
great_tables/gt.py | 6 ------
2 files changed, 23 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 5d1e233f0..3016f8d78 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -181,23 +181,6 @@ class Body:
def __init__(self, body: TblData):
self.body = body
- # def merge_summary_rows(self, old_tbl_data: TblData, summary_rows: SummaryRows):
- # if not summary_rows or summary_rows == []:
- # return self
-
- # tbl_data = self.body
-
- # for i, summary_row in enumerate(summary_rows):
- # # Concatenate based on side parameter
- # if summary_row.side == "bottom":
- # tbl_data = insert_row(tbl_data, summary_row.values, n_rows(tbl_data))
- # else: # top
- # tbl_data = insert_row(tbl_data, summary_row.values, i)
-
- # self.body = tbl_data
-
- # return self
-
def render_formats(self, data_tbl: TblData, formats: list[FormatInfo], context: Any):
for fmt in formats:
eval_func = getattr(fmt.func, context, fmt.func.default)
diff --git a/great_tables/gt.py b/great_tables/gt.py
index a44a179aa..db227822f 100644
--- a/great_tables/gt.py
+++ b/great_tables/gt.py
@@ -315,12 +315,6 @@ def _render_formats(self, context: str) -> Self:
return self._replace(_body=new_body)
def _build_data(self, context: str) -> Self:
- # new_body = self._body.copy()
-
- # # mutation
- # new_body.merge_summary_rows(self._tbl_data, self._summary_rows)
- # self = self._replace(_body=new_body)
-
# Build the body of the table by generating a dictionary
# of lists with cells initially set to nan values
built = self._render_formats(context)
From f310f7e9134eef4b34306fa17f3ca5d08107ad55 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 14:20:47 -0400
Subject: [PATCH 16/54] remove commented out code
---
great_tables/_modify_rows.py | 64 ------------------------------------
1 file changed, 64 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index f09c30d1d..13a9c2116 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -242,70 +242,6 @@ def grand_summary_rows(
return self._replace(_summary_rows=new_summary_rows)
-# def grand_summary_rows(
-# self: GTSelf,
-# fns: list[Literal["min", "max", "mean", "median"]] | Literal["min", "max", "mean", "median"],
-# columns: SelectExpr = None,
-# side: Literal["bottom", "top"] = "bottom",
-# missing_text: str = "---",
-# ) -> GTSelf:
-# if isinstance(fns, str):
-# fns = [fns]
-
-# tbl_data = self._tbl_data
-# new_tbl_data = copy_data(tbl_data)
-
-# original_column_names = get_column_names(tbl_data)
-
-# summary_col_names = resolve_cols_c(data=self, expr=columns)
-
-# # Create summary rows DataFrame
-# for fn_name in fns:
-# summary_row = []
-
-# for col in original_column_names:
-# if col in summary_col_names:
-# col_data = to_list(tbl_data[col])
-
-# if fn_name == "min":
-# new_cell = [min(col_data)]
-# elif fn_name == "max":
-# new_cell = [max(col_data)]
-# elif fn_name == "mean":
-# new_cell = [sum(col_data) / len(col_data)]
-# elif fn_name == "median":
-# new_cell = [quantiles(col_data, n=2)]
-# else:
-# # Should never get here
-# new_cell = ["hi"]
-# else:
-# new_cell = [None]
-
-# summary_row += new_cell
-
-# new_tbl_data = insert_row(new_tbl_data, summary_row, 0)
-
-# # Concatenate based on side parameter
-# # if side == "bottom":
-# # new_data = concat_frames(tbl_data, summary_df)
-# # else: # top
-# # new_data = concat_frames(summary_df, tbl_data)
-
-# self = self._replace(_tbl_data=new_tbl_data)
-
-# _row_group_info = self._boxhead._get_row_group_column()
-# groupname_col = _row_group_info.var if _row_group_info is not None else None
-
-# _row_name_info = self._boxhead._get_stub_column()
-# rowname_col = _row_name_info.var if _row_name_info is not None else None
-
-# stub, boxhead = self._stub._set_cols(self._tbl_data, self._boxhead, rowname_col, groupname_col)
-
-# self._body.body = new_tbl_data
-
-# return self._replace(_stub=stub, _boxhead=boxhead)
-
-
def _calculate_summary_row(
data: GTData,
fn_name: str,
From b8326528c55b747f7194aa61111538a1a9d18dcf Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 14:48:48 -0400
Subject: [PATCH 17/54] Change SummaryRowInfo values from list to dict
---
great_tables/_gt_data.py | 2 +-
great_tables/_modify_rows.py | 40 +++++++++++-------------------
great_tables/_utils_render_html.py | 13 +++-------
3 files changed, 18 insertions(+), 37 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 3016f8d78..e271a73a2 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -983,7 +983,7 @@ class SummaryRowInfo:
"""Information about a single summary row"""
function: Literal["min", "max", "mean", "median"]
- values: list[str | int | float] # TODO: consider datatype
+ values: dict[str, str | int | float] # TODO: consider datatype
side: Literal["top", "bottom"]
group: GroupRowInfo
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 13a9c2116..540e2e97c 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -16,10 +16,6 @@
)
from ._tbl_data import (
SelectExpr,
- create_no_row_frame,
- get_column_names,
- insert_row,
- n_rows,
to_list,
)
@@ -218,19 +214,15 @@ def grand_summary_rows(
# Compute summary rows immediately
summary_row_infos = []
for fn_name in fns:
- row_values_list = _calculate_summary_row(
+ row_values_dict = _calculate_summary_row(
self, fn_name, columns, missing_text, group_id=None
)
# TODO: minimize to one new df function, don't need insert row elsewhere.
- # Maybe don't even need this to be a SeriesLike or DataFrameLike
- # Convert list of values to TblData (single row DataFrame)
- summary_tbl_data = create_no_row_frame(self._tbl_data)
- summary_tbl_data = insert_row(summary_tbl_data, row_values_list, n_rows(summary_tbl_data))
summary_row_info = SummaryRowInfo(
function=fn_name,
- values=row_values_list, # TODO: revisit type
+ values=row_values_dict, # TODO: revisit type
side=side,
group=GRAND_SUMMARY_GROUP,
)
@@ -248,11 +240,9 @@ def _calculate_summary_row(
columns: SelectExpr,
missing_text: str,
group_id: str | None = None, # None means grand summary (all data)
-) -> list[Any]:
+) -> dict[str, Any]:
"""Calculate a summary row based on the function name and selected columns for a specific group."""
- tbl_data = data._tbl_data
-
- original_column_names = get_column_names(tbl_data)
+ original_columns = data._boxhead._get_columns()
summary_col_names = resolve_cols_c(data=data, expr=columns)
@@ -262,26 +252,24 @@ def _calculate_summary_row(
# Future: group-specific logic would go here
raise NotImplementedError("Group-specific summaries not yet implemented")
- # Create summary rows _tbl_data
- summary_row = []
+ # Create summary row data as dict
+ summary_row = {}
- for col in original_column_names:
+ for col in original_columns:
if col in summary_col_names:
- col_data = to_list(tbl_data[col])
+ col_data = to_list(data._tbl_data[col])
if fn_name == "min":
- new_cell = [min(col_data)]
+ summary_row[col] = min(col_data)
elif fn_name == "max":
- new_cell = [max(col_data)]
+ summary_row[col] = max(col_data)
elif fn_name == "mean":
- new_cell = [sum(col_data) / len(col_data)]
+ summary_row[col] = sum(col_data) / len(col_data)
elif fn_name == "median":
- new_cell = [quantiles(col_data, n=2)]
+ summary_row[col] = quantiles(col_data, n=2)[0] # Consider using the one in nanoplot
else:
- # Should never get here
- new_cell = ["hi"]
+ summary_row[col] = "hi" # Should never get here
else:
- new_cell = [missing_text]
+ summary_row[col] = missing_text
- summary_row += new_cell
return summary_row
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 20c3abf6a..ed4c865f2 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -448,16 +448,9 @@ def _create_row_component_h(
if colinfo == stub_var:
cell_content = summary_row.function.capitalize()
else:
- non_stub_cols = [col for col in column_vars if col != stub_var]
- try:
- col_index = non_stub_cols.index(colinfo)
- cell_content = (
- summary_row.values[col_index]
- if col_index < len(summary_row.values)
- else "---"
- )
- except ValueError:
- cell_content = "---"
+ # hopefully don't need fallback
+ cell_content = summary_row.values.get(colinfo.var)
+
else:
cell_content = _get_cell(tbl_data, row_index, colinfo.var)
From 6e9af0acae3d6d04f2e03c9cbb56e21b800708bf Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 14:49:35 -0400
Subject: [PATCH 18/54] reorder functions
---
great_tables/_utils_render_html.py | 146 ++++++++++++++---------------
1 file changed, 73 insertions(+), 73 deletions(-)
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index ed4c865f2..1e2f68595 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -425,79 +425,6 @@ def create_columns_component_h(data: GTData) -> str:
return table_col_headings
-def _create_row_component_h(
- column_vars: list[ColInfo],
- stub_var: ColInfo | None,
- has_stub_column: bool,
- apply_stub_striping: bool,
- apply_body_striping: bool,
- styles_cells: list[StyleInfo], # Either styles_cells OR styles_grand_summary
- styles_labels: list[StyleInfo], # Either styles_row_label OR styles_grand_summary_label
- row_index: int | None = None, # For data rows
- summary_row: SummaryRowInfo | None = None, # For summary rows
- tbl_data: TblData | None = None,
-) -> str:
- """Create a single table row (either data row or summary row)"""
-
- is_summary_row = summary_row is not None
- body_cells: list[str] = []
-
- for colinfo in column_vars:
- # Get cell content
- if is_summary_row:
- if colinfo == stub_var:
- cell_content = summary_row.function.capitalize()
- else:
- # hopefully don't need fallback
- cell_content = summary_row.values.get(colinfo.var)
-
- else:
- cell_content = _get_cell(tbl_data, row_index, colinfo.var)
-
- cell_str = str(cell_content)
- is_stub_cell = has_stub_column and colinfo.var == stub_var.var
- cell_alignment = colinfo.defaulted_align
-
- # Get styles
- if is_summary_row:
- _body_styles = styles_cells # Already filtered to grand summary styles
- _rowname_styles = (
- styles_labels if is_stub_cell else []
- ) # Already filtered to grand summary label styles
- else:
- _body_styles = [
- x for x in styles_cells if x.rownum == row_index and x.colname == colinfo.var
- ]
- _rowname_styles = (
- [x for x in styles_labels if x.rownum == row_index] if is_stub_cell else []
- )
-
- # Build classes and element
- if is_stub_cell:
- el_name = "th"
- classes = ["gt_row", "gt_left", "gt_stub"]
- if is_summary_row:
- classes.append("gt_grand_summary")
- if apply_stub_striping:
- classes.append("gt_striped")
- else:
- el_name = "td"
- classes = ["gt_row", f"gt_{cell_alignment}"]
- if is_summary_row:
- classes.append("gt_grand_summary")
- if apply_body_striping:
- classes.append("gt_striped")
-
- classes_str = " ".join(classes)
- cell_styles = _flatten_styles(_body_styles + _rowname_styles, wrap=True)
-
- body_cells.append(
- f""" <{el_name}{cell_styles} class="{classes_str}">{cell_str}{el_name}>"""
- )
-
- return " \n" + "\n".join(body_cells) + "\n
"
-
-
def create_body_component_h(data: GTData) -> str:
# for now, just coerce everything in the original data to a string
# so we can fill in the body data with it
@@ -630,6 +557,79 @@ def create_body_component_h(data: GTData) -> str:
"""
+def _create_row_component_h(
+ column_vars: list[ColInfo],
+ stub_var: ColInfo | None,
+ has_stub_column: bool,
+ apply_stub_striping: bool,
+ apply_body_striping: bool,
+ styles_cells: list[StyleInfo], # Either styles_cells OR styles_grand_summary
+ styles_labels: list[StyleInfo], # Either styles_row_label OR styles_grand_summary_label
+ row_index: int | None = None, # For data rows
+ summary_row: SummaryRowInfo | None = None, # For summary rows
+ tbl_data: TblData | None = None,
+) -> str:
+ """Create a single table row (either data row or summary row)"""
+
+ is_summary_row = summary_row is not None
+ body_cells: list[str] = []
+
+ for colinfo in column_vars:
+ # Get cell content
+ if is_summary_row:
+ if colinfo == stub_var:
+ cell_content = summary_row.function.capitalize()
+ else:
+ # hopefully don't need fallback
+ cell_content = summary_row.values.get(colinfo.var)
+
+ else:
+ cell_content = _get_cell(tbl_data, row_index, colinfo.var)
+
+ cell_str = str(cell_content)
+ is_stub_cell = has_stub_column and colinfo.var == stub_var.var
+ cell_alignment = colinfo.defaulted_align
+
+ # Get styles
+ if is_summary_row:
+ _body_styles = styles_cells # Already filtered to grand summary styles
+ _rowname_styles = (
+ styles_labels if is_stub_cell else []
+ ) # Already filtered to grand summary label styles
+ else:
+ _body_styles = [
+ x for x in styles_cells if x.rownum == row_index and x.colname == colinfo.var
+ ]
+ _rowname_styles = (
+ [x for x in styles_labels if x.rownum == row_index] if is_stub_cell else []
+ )
+
+ # Build classes and element
+ if is_stub_cell:
+ el_name = "th"
+ classes = ["gt_row", "gt_left", "gt_stub"]
+ if is_summary_row:
+ classes.append("gt_grand_summary")
+ if apply_stub_striping:
+ classes.append("gt_striped")
+ else:
+ el_name = "td"
+ classes = ["gt_row", f"gt_{cell_alignment}"]
+ if is_summary_row:
+ classes.append("gt_grand_summary")
+ if apply_body_striping:
+ classes.append("gt_striped")
+
+ classes_str = " ".join(classes)
+ cell_styles = _flatten_styles(_body_styles + _rowname_styles, wrap=True)
+
+ body_cells.append(
+ f""" <{el_name}{cell_styles} class="{classes_str}">{cell_str}{el_name}>"""
+ )
+
+ return " \n" + "\n".join(body_cells) + "\n
"
+
+
def create_source_notes_component_h(data: GTData) -> str:
source_notes = data._source_notes
From 986d8a6eb1cf044e0058f4516cc0cfd1560e0ec0 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 14:50:02 -0400
Subject: [PATCH 19/54] remove dead code
---
great_tables/_tbl_data.py | 69 ---------------------------------------
1 file changed, 69 deletions(-)
diff --git a/great_tables/_tbl_data.py b/great_tables/_tbl_data.py
index cd9377bfc..43798e364 100644
--- a/great_tables/_tbl_data.py
+++ b/great_tables/_tbl_data.py
@@ -874,72 +874,3 @@ def _(ser: PyArrowChunkedArray, name: Optional[str] = None) -> PyArrowTable:
import pyarrow as pa
return pa.table({name: ser})
-
-
-# insert_row ----
-
-
-@singledispatch
-def insert_row(df: DataFrameLike, row_data: list, index: int) -> DataFrameLike:
- """Insert a single row into a DataFrame at the specified index"""
- raise NotImplementedError(f"Unsupported type: {type(df)}")
-
-
-@insert_row.register
-def _(df: PdDataFrame, row_data: list, index: int) -> PdDataFrame:
- import pandas as pd
-
- new_row = pd.DataFrame([row_data], columns=df.columns)
- before = df.iloc[:index]
- after = df.iloc[index:]
- return pd.concat([before, new_row, after], ignore_index=True)
-
-
-@insert_row.register
-def _(df: PlDataFrame, row_data: list, index: int) -> PlDataFrame:
- import polars as pl
-
- row_dict = dict(zip(df.columns, row_data))
- new_row = pl.DataFrame([row_dict])
- before = df[:index]
- after = df[index:]
- return before.vstack(new_row).vstack(after)
-
-
-@insert_row.register
-def _(df: PyArrowTable, row_data: list, index: int) -> PyArrowTable:
- import pyarrow as pa
-
- row_dict = dict(zip(df.column_names, row_data))
- new_row = pa.table([row_dict])
- before = df.slice(0, index)
- after = df.slice(index)
- return pa.concat_tables([before, new_row, after])
-
-
-# create_no_row_frame ----
-
-
-@singledispatch
-def create_no_row_frame(df: DataFrameLike) -> DataFrameLike:
- """Return a DataFrame with the same columns but no rows"""
- raise NotImplementedError(f"Unsupported type: {type(df)}")
-
-
-@create_no_row_frame.register
-def _(df: PdDataFrame):
- import pandas as pd
-
- return pd.DataFrame(columns=df.columns).astype(df.dtypes)
-
-
-@create_no_row_frame.register
-def _(df: PlDataFrame):
- return df.clear()
-
-
-@create_no_row_frame.register
-def _(df: PyArrowTable):
- import pyarrow as pa
-
- return pa.table({col: pa.array([], type=df.column(col).type) for col in df.column_names})
From aab38b979899a59681acf7ba6fb9c3ae4103c7d6 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 14:52:44 -0400
Subject: [PATCH 20/54] support grand summary rows at bottom of table
---
great_tables/_utils_render_html.py | 23 ++++++++++++++++-------
1 file changed, 16 insertions(+), 7 deletions(-)
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 1e2f68595..42a6f8166 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -470,9 +470,9 @@ def create_body_component_h(data: GTData) -> str:
# Load summary rows
summary_rows_dict = data._summary_rows.summary_rows_dict()
-
- # Add grand summary rows at top (no striping for summary rows)
grand_summary_rows = summary_rows_dict.get(GRAND_SUMMARY_GROUP.group_id, [])
+
+ # Add grand summary rows at top
for summary_row in grand_summary_rows:
if summary_row.side == "top":
row_html = _create_row_component_h(
@@ -499,8 +499,6 @@ def create_body_component_h(data: GTData) -> str:
odd_j_row = j % 2 == 1
- body_cells: list[str] = []
-
# Create table row specifically for group (if applicable)
if has_stub_column and has_groups and not has_two_col_stub:
colspan_value = data._boxhead._get_effective_number_of_columns(
@@ -542,12 +540,23 @@ def create_body_component_h(data: GTData) -> str:
prev_group_info = group_info
- # unused code
- # body_rows.append(" \n" + "\n".join(body_cells) + "\n
")
-
## after the last row in the group, we need to append the summary rows for the group
## if this table has summary rows
+ for summary_row in grand_summary_rows:
+ if summary_row.side == "bottom":
+ row_html = _create_row_component_h(
+ column_vars=column_vars,
+ stub_var=stub_var,
+ has_stub_column=has_stub_column,
+ apply_stub_striping=False, # No striping for summary rows
+ apply_body_striping=False, # No striping for summary rows
+ styles_cells=styles_grand_summary,
+ styles_labels=styles_grand_summary_label,
+ summary_row=summary_row,
+ )
+ body_rows.append(row_html)
+
## outside of the standard body rows loop, we need to add summary rows that are grand here
all_body_rows = "\n".join(body_rows)
From bf372cfaa40dea89272bf564d3d70c38f3f8c687 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 15:01:44 -0400
Subject: [PATCH 21/54] location mask class var added
---
great_tables/_locations.py | 2 ++
great_tables/_utils_render_html.py | 3 +--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index d4355f12f..f25e514b7 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -560,6 +560,7 @@ class LocSummary(Loc):
# TODO: these can be tidyselectors
columns: SelectExpr = None
rows: RowSelectExpr = None
+ mask: PlExpr | None = None
@dataclass
@@ -567,6 +568,7 @@ class LocGrandSummary(Loc):
# TODO: these can be tidyselectors
columns: SelectExpr = None
rows: RowSelectExpr = None
+ mask: PlExpr | None = None
@dataclass
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 42a6f8166..14ed71889 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -543,6 +543,7 @@ def create_body_component_h(data: GTData) -> str:
## after the last row in the group, we need to append the summary rows for the group
## if this table has summary rows
+ # Add bottom grand summary rows
for summary_row in grand_summary_rows:
if summary_row.side == "bottom":
row_html = _create_row_component_h(
@@ -557,8 +558,6 @@ def create_body_component_h(data: GTData) -> str:
)
body_rows.append(row_html)
- ## outside of the standard body rows loop, we need to add summary rows that are grand here
-
all_body_rows = "\n".join(body_rows)
return f"""
From 3765a48bc7c8bc73d698acc43e0d71330d98d33f Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 15:04:48 -0400
Subject: [PATCH 22/54] support sum
---
great_tables/_modify_rows.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 540e2e97c..cec5d7074 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -197,7 +197,8 @@ def with_id(self: GTSelf, id: str | None = None) -> GTSelf:
def grand_summary_rows(
self: GTSelf,
- fns: list[Literal["min", "max", "mean", "median"]] | Literal["min", "max", "mean", "median"],
+ fns: list[Literal["min", "max", "mean", "median", "sum"]]
+ | Literal["min", "max", "mean", "median"],
columns: SelectExpr = None,
side: Literal["bottom", "top"] = "bottom",
missing_text: str = "---",
@@ -267,6 +268,8 @@ def _calculate_summary_row(
summary_row[col] = sum(col_data) / len(col_data)
elif fn_name == "median":
summary_row[col] = quantiles(col_data, n=2)[0] # Consider using the one in nanoplot
+ elif fn_name == "sum":
+ summary_row[col] = sum(col_data)
else:
summary_row[col] = "hi" # Should never get here
else:
From ae9caf1ab5e4221b89a9085231c68d0d2fe49994 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 16:33:10 -0400
Subject: [PATCH 23/54] support opt_stylize and other tab_options
---
great_tables/_gt_data.py | 38 +++++------
great_tables/_options.py | 20 +++---
great_tables/_utils_render_html.py | 4 +-
great_tables/css/gt_styles_default.scss | 22 +++++++
tests/__snapshots__/test_export.ambr | 3 +
tests/__snapshots__/test_options.ambr | 88 +++++++++++++++++++++++++
tests/__snapshots__/test_repr.ambr | 15 +++++
tests/__snapshots__/test_scss.ambr | 22 +++++++
8 files changed, 182 insertions(+), 30 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index e271a73a2..214cb99f8 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -1167,25 +1167,25 @@ class Options:
# summary_row_border_style: OptionsInfo = OptionsInfo(True, "summary_row", "value", "solid")
# summary_row_border_width: OptionsInfo = OptionsInfo(True, "summary_row", "px", "2px")
# summary_row_border_color: OptionsInfo = OptionsInfo(True, "summary_row", "value", "#D3D3D3")
- # grand_summary_row_padding: OptionsInfo = OptionsInfo(True, "grand_summary_row", "px", "8px")
- # grand_summary_row_padding_horizontal: OptionsInfo = OptionsInfo(
- # True, "grand_summary_row", "px", "5px"
- # )
- # grand_summary_row_background_color: OptionsInfo = OptionsInfo(
- # True, "grand_summary_row", "value", None
- # )
- # grand_summary_row_text_transform: OptionsInfo = OptionsInfo(
- # True, "grand_summary_row", "value", "inherit"
- # )
- # grand_summary_row_border_style: OptionsInfo = OptionsInfo(
- # True, "grand_summary_row", "value", "double"
- # )
- # grand_summary_row_border_width: OptionsInfo = OptionsInfo(
- # True, "grand_summary_row", "px", "6px"
- # )
- # grand_summary_row_border_color: OptionsInfo = OptionsInfo(
- # True, "grand_summary_row", "value", "#D3D3D3"
- # )
+ grand_summary_row_padding: OptionsInfo = OptionsInfo(True, "grand_summary_row", "px", "8px")
+ grand_summary_row_padding_horizontal: OptionsInfo = OptionsInfo(
+ True, "grand_summary_row", "px", "5px"
+ )
+ grand_summary_row_background_color: OptionsInfo = OptionsInfo(
+ True, "grand_summary_row", "value", None
+ )
+ grand_summary_row_text_transform: OptionsInfo = OptionsInfo(
+ True, "grand_summary_row", "value", "inherit"
+ )
+ grand_summary_row_border_style: OptionsInfo = OptionsInfo(
+ True, "grand_summary_row", "value", "double"
+ )
+ grand_summary_row_border_width: OptionsInfo = OptionsInfo(
+ True, "grand_summary_row", "px", "6px"
+ )
+ grand_summary_row_border_color: OptionsInfo = OptionsInfo(
+ True, "grand_summary_row", "value", "#D3D3D3"
+ )
# footnotes_font_size: OptionsInfo = OptionsInfo(True, "footnotes", "px", "90%")
# footnotes_padding: OptionsInfo = OptionsInfo(True, "footnotes", "px", "4px")
# footnotes_padding_horizontal: OptionsInfo = OptionsInfo(True, "footnotes", "px", "5px")
diff --git a/great_tables/_options.py b/great_tables/_options.py
index f4ab0fc8a..7aab82624 100644
--- a/great_tables/_options.py
+++ b/great_tables/_options.py
@@ -128,13 +128,13 @@ def tab_options(
# summary_row_border_style: str | None = None,
# summary_row_border_width: str | None = None,
# summary_row_border_color: str | None = None,
- # grand_summary_row_background_color: str | None = None,
- # grand_summary_row_text_transform: str | None = None,
- # grand_summary_row_padding: str | None = None,
- # grand_summary_row_padding_horizontal: str | None = None,
- # grand_summary_row_border_style: str | None = None,
- # grand_summary_row_border_width: str | None = None,
- # grand_summary_row_border_color: str | None = None,
+ grand_summary_row_background_color: str | None = None,
+ grand_summary_row_text_transform: str | None = None,
+ grand_summary_row_padding: str | None = None,
+ grand_summary_row_padding_horizontal: str | None = None,
+ grand_summary_row_border_style: str | None = None,
+ grand_summary_row_border_width: str | None = None,
+ grand_summary_row_border_color: str | None = None,
# footnotes_background_color: str | None = None,
# footnotes_font_size: str | None = None,
# footnotes_padding: str | None = None,
@@ -1386,10 +1386,8 @@ def opt_stylize(
# Omit keys that are not needed for the `tab_options()` method
# TODO: the omitted keys are for future use when:
# (1) summary rows are implemented
- # (2) grand summary rows are implemented
omit_keys = {
"summary_row_background_color",
- "grand_summary_row_background_color",
}
def dict_omit_keys(dict: dict[str, str], omit_keys: set[str]) -> dict[str, str]:
@@ -1440,6 +1438,8 @@ class StyleMapper:
data_vlines_style: str
data_vlines_color: str
row_striping_background_color: str
+ grand_summary_row_background_color: str
+ # summary_row_background_color: str
mappings: ClassVar[dict[str, list[str]]] = {
"table_hlines_color": ["table_border_top_color", "table_border_bottom_color"],
@@ -1461,6 +1461,8 @@ class StyleMapper:
"data_vlines_style": ["table_body_vlines_style"],
"data_vlines_color": ["table_body_vlines_color"],
"row_striping_background_color": ["row_striping_background_color"],
+ "grand_summary_row_background_color": ["grand_summary_row_background_color"],
+ # "summary_row_background_color": ["summary_row_background_color"],
}
def map_entry(self, name: str) -> dict[str, list[str]]:
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 14ed71889..738ff75b6 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -617,14 +617,14 @@ def _create_row_component_h(
el_name = "th"
classes = ["gt_row", "gt_left", "gt_stub"]
if is_summary_row:
- classes.append("gt_grand_summary")
+ classes.append("gt_grand_summary_row")
if apply_stub_striping:
classes.append("gt_striped")
else:
el_name = "td"
classes = ["gt_row", f"gt_{cell_alignment}"]
if is_summary_row:
- classes.append("gt_grand_summary")
+ classes.append("gt_grand_summary_row")
if apply_body_striping:
classes.append("gt_striped")
diff --git a/great_tables/css/gt_styles_default.scss b/great_tables/css/gt_styles_default.scss
index 1fba67e43..6fa9f6632 100644
--- a/great_tables/css/gt_styles_default.scss
+++ b/great_tables/css/gt_styles_default.scss
@@ -276,6 +276,28 @@ p {
border-bottom-color: $table_body_border_bottom_color;
}
+.gt_grand_summary_row {
+ color: $font_color_grand_summary_row_background_color;
+ background-color: $grand_summary_row_background_color;
+ text-transform: $grand_summary_row_text_transform;
+ padding-top: $grand_summary_row_padding;
+ padding-bottom: $grand_summary_row_padding;
+ padding-left: $grand_summary_row_padding_horizontal;
+ padding-right: $grand_summary_row_padding_horizontal;
+}
+
+.gt_first_grand_summary_row_bottom {
+ border-top-style: $grand_summary_row_border_style;
+ border-top-width: $grand_summary_row_border_width;
+ border-top-color: $grand_summary_row_border_color;
+}
+
+.gt_last_grand_summary_row_top {
+ border-bottom-style: $grand_summary_row_border_style;
+ border-bottom-width: $grand_summary_row_border_width;
+ border-bottom-color: $grand_summary_row_border_color;
+}
+
.gt_sourcenotes {
color: $font_color_source_notes_background_color;
background-color: $source_notes_background_color;
diff --git a/tests/__snapshots__/test_export.ambr b/tests/__snapshots__/test_export.ambr
index b6fe21790..fd274ca9f 100644
--- a/tests/__snapshots__/test_export.ambr
+++ b/tests/__snapshots__/test_export.ambr
@@ -36,6 +36,9 @@
#test_table .gt_row_group_first th { border-top-width: 2px; }
#test_table .gt_striped { background-color: rgba(128,128,128,0.05); }
#test_table .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
+ #test_table .gt_grand_summary_row { color: #333333; background-color: #FFFFFF; text-transform: inherit; padding-top: 8px; padding-bottom: 8px; padding-left: 5px; padding-right: 5px; }
+ #test_table .gt_first_grand_summary_row_bottom { border-top-style: double; border-top-width: 6px; border-top-color: #D3D3D3; }
+ #test_table .gt_last_grand_summary_row_top { border-bottom-style: double; border-bottom-width: 6px; border-bottom-color: #D3D3D3; }
#test_table .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
#test_table .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test_table .gt_left { text-align: left; }
diff --git a/tests/__snapshots__/test_options.ambr b/tests/__snapshots__/test_options.ambr
index d4661e920..38f8c919e 100644
--- a/tests/__snapshots__/test_options.ambr
+++ b/tests/__snapshots__/test_options.ambr
@@ -1023,6 +1023,28 @@
border-bottom-color: #0076BA;
}
+ #abc .gt_grand_summary_row {
+ color: #333333;
+ background-color: #89D3FE;
+ text-transform: inherit;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_first_grand_summary_row_bottom {
+ border-top-style: double;
+ border-top-width: 6px;
+ border-top-color: #D3D3D3;
+ }
+
+ #abc .gt_last_grand_summary_row_top {
+ border-bottom-style: double;
+ border-bottom-width: 6px;
+ border-bottom-color: #D3D3D3;
+ }
+
#abc .gt_sourcenotes {
color: #333333;
background-color: #FFFFFF;
@@ -1374,6 +1396,28 @@
border-bottom-color: #0076BA;
}
+ #abc .gt_grand_summary_row {
+ color: #333333;
+ background-color: #89D3FE;
+ text-transform: inherit;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_first_grand_summary_row_bottom {
+ border-top-style: double;
+ border-top-width: 6px;
+ border-top-color: #D3D3D3;
+ }
+
+ #abc .gt_last_grand_summary_row_top {
+ border-bottom-style: double;
+ border-bottom-width: 6px;
+ border-bottom-color: #D3D3D3;
+ }
+
#abc .gt_sourcenotes {
color: #333333;
background-color: #FFFFFF;
@@ -1833,6 +1877,28 @@
border-bottom-color: red;
}
+ #abc .gt_grand_summary_row {
+ color: #000000;
+ background-color: red;
+ text-transform: inherit;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_first_grand_summary_row_bottom {
+ border-top-style: double;
+ border-top-width: 6px;
+ border-top-color: #D3D3D3;
+ }
+
+ #abc .gt_last_grand_summary_row_top {
+ border-bottom-style: double;
+ border-bottom-width: 6px;
+ border-bottom-color: #D3D3D3;
+ }
+
#abc .gt_sourcenotes {
color: #000000;
background-color: red;
@@ -2184,6 +2250,28 @@
border-bottom-color: #D3D3D3;
}
+ #abc .gt_grand_summary_row {
+ color: #333333;
+ background-color: #FFFFFF;
+ text-transform: inherit;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_first_grand_summary_row_bottom {
+ border-top-style: double;
+ border-top-width: 6px;
+ border-top-color: #D3D3D3;
+ }
+
+ #abc .gt_last_grand_summary_row_top {
+ border-bottom-style: double;
+ border-bottom-width: 6px;
+ border-bottom-color: #D3D3D3;
+ }
+
#abc .gt_sourcenotes {
color: #333333;
background-color: #FFFFFF;
diff --git a/tests/__snapshots__/test_repr.ambr b/tests/__snapshots__/test_repr.ambr
index 129c5ec8c..cd0513a4a 100644
--- a/tests/__snapshots__/test_repr.ambr
+++ b/tests/__snapshots__/test_repr.ambr
@@ -36,6 +36,9 @@
#test .gt_row_group_first th { border-top-width: 2px; }
#test .gt_striped { background-color: rgba(128,128,128,0.05); }
#test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
+ #test .gt_grand_summary_row { color: #333333; background-color: #FFFFFF; text-transform: inherit; padding-top: 8px; padding-bottom: 8px; padding-left: 5px; padding-right: 5px; }
+ #test .gt_first_grand_summary_row_bottom { border-top-style: double; border-top-width: 6px; border-top-color: #D3D3D3; }
+ #test .gt_last_grand_summary_row_top { border-bottom-style: double; border-bottom-width: 6px; border-bottom-color: #D3D3D3; }
#test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
#test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_left { text-align: left; }
@@ -112,6 +115,9 @@
#test .gt_row_group_first th { border-top-width: 2px; }
#test .gt_striped { background-color: rgba(128,128,128,0.05); }
#test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
+ #test .gt_grand_summary_row { color: #333333; background-color: #FFFFFF; text-transform: inherit; padding-top: 8px; padding-bottom: 8px; padding-left: 5px; padding-right: 5px; }
+ #test .gt_first_grand_summary_row_bottom { border-top-style: double; border-top-width: 6px; border-top-color: #D3D3D3; }
+ #test .gt_last_grand_summary_row_top { border-bottom-style: double; border-bottom-width: 6px; border-bottom-color: #D3D3D3; }
#test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
#test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_left { text-align: left; }
@@ -194,6 +200,9 @@
#test .gt_row_group_first th { border-top-width: 2px !important; }
#test .gt_striped { background-color: rgba(128,128,128,0.05) !important; }
#test .gt_table_body { border-top-style: solid !important; border-top-width: 2px !important; border-top-color: #D3D3D3 !important; border-bottom-style: solid !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; }
+ #test .gt_grand_summary_row { color: #333333 !important; background-color: #FFFFFF !important; text-transform: inherit !important; padding-top: 8px !important; padding-bottom: 8px !important; padding-left: 5px !important; padding-right: 5px !important; }
+ #test .gt_first_grand_summary_row_bottom { border-top-style: double !important; border-top-width: 6px !important; border-top-color: #D3D3D3 !important; }
+ #test .gt_last_grand_summary_row_top { border-bottom-style: double !important; border-bottom-width: 6px !important; border-bottom-color: #D3D3D3 !important; }
#test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
#test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; }
#test .gt_left { text-align: left !important; }
@@ -273,6 +282,9 @@
#test .gt_row_group_first th { border-top-width: 2px; }
#test .gt_striped { background-color: rgba(128,128,128,0.05); }
#test .gt_table_body { border-top-style: solid; border-top-width: 2px; border-top-color: #D3D3D3; border-bottom-style: solid; border-bottom-width: 2px; border-bottom-color: #D3D3D3; }
+ #test .gt_grand_summary_row { color: #333333; background-color: #FFFFFF; text-transform: inherit; padding-top: 8px; padding-bottom: 8px; padding-left: 5px; padding-right: 5px; }
+ #test .gt_first_grand_summary_row_bottom { border-top-style: double; border-top-width: 6px; border-top-color: #D3D3D3; }
+ #test .gt_last_grand_summary_row_top { border-bottom-style: double; border-bottom-width: 6px; border-bottom-color: #D3D3D3; }
#test .gt_sourcenotes { color: #333333; background-color: #FFFFFF; border-bottom-style: none; border-bottom-width: 2px; border-bottom-color: #D3D3D3; border-left-style: none; border-left-width: 2px; border-left-color: #D3D3D3; border-right-style: none; border-right-width: 2px; border-right-color: #D3D3D3; }
#test .gt_sourcenote { font-size: 90%; padding-top: 4px; padding-bottom: 4px; padding-left: 5px; padding-right: 5px; text-align: left; }
#test .gt_left { text-align: left; }
@@ -349,6 +361,9 @@
#test .gt_row_group_first th { border-top-width: 2px !important; }
#test .gt_striped { background-color: rgba(128,128,128,0.05) !important; }
#test .gt_table_body { border-top-style: solid !important; border-top-width: 2px !important; border-top-color: #D3D3D3 !important; border-bottom-style: solid !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; }
+ #test .gt_grand_summary_row { color: #333333 !important; background-color: #FFFFFF !important; text-transform: inherit !important; padding-top: 8px !important; padding-bottom: 8px !important; padding-left: 5px !important; padding-right: 5px !important; }
+ #test .gt_first_grand_summary_row_bottom { border-top-style: double !important; border-top-width: 6px !important; border-top-color: #D3D3D3 !important; }
+ #test .gt_last_grand_summary_row_top { border-bottom-style: double !important; border-bottom-width: 6px !important; border-bottom-color: #D3D3D3 !important; }
#test .gt_sourcenotes { color: #333333 !important; background-color: #FFFFFF !important; border-bottom-style: none !important; border-bottom-width: 2px !important; border-bottom-color: #D3D3D3 !important; border-left-style: none !important; border-left-width: 2px !important; border-left-color: #D3D3D3 !important; border-right-style: none !important; border-right-width: 2px !important; border-right-color: #D3D3D3 !important; }
#test .gt_sourcenote { font-size: 90% !important; padding-top: 4px !important; padding-bottom: 4px !important; padding-left: 5px !important; padding-right: 5px !important; text-align: left !important; }
#test .gt_left { text-align: left !important; }
diff --git a/tests/__snapshots__/test_scss.ambr b/tests/__snapshots__/test_scss.ambr
index 00b3549db..384c33ca3 100644
--- a/tests/__snapshots__/test_scss.ambr
+++ b/tests/__snapshots__/test_scss.ambr
@@ -285,6 +285,28 @@
border-bottom-color: #D3D3D3;
}
+ #abc .gt_grand_summary_row {
+ color: #333333;
+ background-color: #FFFFFF;
+ text-transform: inherit;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ #abc .gt_first_grand_summary_row_bottom {
+ border-top-style: double;
+ border-top-width: 6px;
+ border-top-color: #D3D3D3;
+ }
+
+ #abc .gt_last_grand_summary_row_top {
+ border-bottom-style: double;
+ border-bottom-width: 6px;
+ border-bottom-color: #D3D3D3;
+ }
+
#abc .gt_sourcenotes {
color: #333333;
background-color: #FFFFFF;
From 52741b57317578501878399ae6f8cf9a554a73e4 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 14 Aug 2025 17:05:51 -0400
Subject: [PATCH 24/54] Apply special classes to get double border
---
great_tables/_utils_render_html.py | 66 +++++++++++++++++-------------
1 file changed, 37 insertions(+), 29 deletions(-)
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 738ff75b6..fa7f62c25 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -473,19 +473,20 @@ def create_body_component_h(data: GTData) -> str:
grand_summary_rows = summary_rows_dict.get(GRAND_SUMMARY_GROUP.group_id, [])
# Add grand summary rows at top
- for summary_row in grand_summary_rows:
- if summary_row.side == "top":
- row_html = _create_row_component_h(
- column_vars=column_vars,
- stub_var=stub_var,
- has_stub_column=has_stub_column,
- apply_stub_striping=False, # No striping for summary rows
- apply_body_striping=False, # No striping for summary rows
- styles_cells=styles_grand_summary,
- styles_labels=styles_grand_summary_label,
- summary_row=summary_row,
- )
- body_rows.append(row_html)
+ top_summary_rows = [row for row in grand_summary_rows if row.side == "top"]
+ for i, summary_row in enumerate(top_summary_rows):
+ row_html = _create_row_component_h(
+ column_vars=column_vars,
+ stub_var=stub_var,
+ has_stub_column=has_stub_column,
+ apply_stub_striping=False, # No striping for summary rows
+ apply_body_striping=False, # No striping for summary rows
+ styles_cells=styles_grand_summary,
+ styles_labels=styles_grand_summary_label,
+ summary_row=summary_row,
+ css_class="gt_last_grand_summary_row_top" if i == len(top_summary_rows) - 1 else None,
+ )
+ body_rows.append(row_html)
# iterate over rows (ordered by groupings)
prev_group_info = None
@@ -543,20 +544,21 @@ def create_body_component_h(data: GTData) -> str:
## after the last row in the group, we need to append the summary rows for the group
## if this table has summary rows
- # Add bottom grand summary rows
- for summary_row in grand_summary_rows:
- if summary_row.side == "bottom":
- row_html = _create_row_component_h(
- column_vars=column_vars,
- stub_var=stub_var,
- has_stub_column=has_stub_column,
- apply_stub_striping=False, # No striping for summary rows
- apply_body_striping=False, # No striping for summary rows
- styles_cells=styles_grand_summary,
- styles_labels=styles_grand_summary_label,
- summary_row=summary_row,
- )
- body_rows.append(row_html)
+ # Add grand summary rows at bottom
+ bottom_summary_rows = [row for row in grand_summary_rows if row.side == "bottom"]
+ for i, summary_row in enumerate(bottom_summary_rows):
+ row_html = _create_row_component_h(
+ column_vars=column_vars,
+ stub_var=stub_var,
+ has_stub_column=has_stub_column,
+ apply_stub_striping=False, # No striping for summary rows
+ apply_body_striping=False, # No striping for summary rows
+ styles_cells=styles_grand_summary,
+ styles_labels=styles_grand_summary_label,
+ summary_row=summary_row,
+ css_class="gt_first_grand_summary_row_bottom" if i == 0 else None,
+ )
+ body_rows.append(row_html)
all_body_rows = "\n".join(body_rows)
@@ -576,6 +578,7 @@ def _create_row_component_h(
row_index: int | None = None, # For data rows
summary_row: SummaryRowInfo | None = None, # For summary rows
tbl_data: TblData | None = None,
+ css_class: str | None = None,
) -> str:
"""Create a single table row (either data row or summary row)"""
@@ -594,6 +597,11 @@ def _create_row_component_h(
else:
cell_content = _get_cell(tbl_data, row_index, colinfo.var)
+ if css_class:
+ classes = [css_class]
+ else:
+ classes = []
+
cell_str = str(cell_content)
is_stub_cell = has_stub_column and colinfo.var == stub_var.var
cell_alignment = colinfo.defaulted_align
@@ -615,14 +623,14 @@ def _create_row_component_h(
# Build classes and element
if is_stub_cell:
el_name = "th"
- classes = ["gt_row", "gt_left", "gt_stub"]
+ classes += ["gt_row", "gt_left", "gt_stub"]
if is_summary_row:
classes.append("gt_grand_summary_row")
if apply_stub_striping:
classes.append("gt_striped")
else:
el_name = "td"
- classes = ["gt_row", f"gt_{cell_alignment}"]
+ classes += ["gt_row", f"gt_{cell_alignment}"]
if is_summary_row:
classes.append("gt_grand_summary_row")
if apply_body_striping:
From 7e201dfcf570eb3ea1c949b4a1d8f9ba677eb8ff Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 15:57:51 -0400
Subject: [PATCH 25/54] clean up add summary and set up styling
---
great_tables/_gt_data.py | 74 +++++++++++++++++++++++++-----
great_tables/_modify_rows.py | 12 ++---
great_tables/_utils_render_html.py | 21 +++------
3 files changed, 73 insertions(+), 34 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 214cb99f8..d32f9fc23 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -982,6 +982,7 @@ def __init__(self, func: FormatFns, cols: list[str], rows: list[int]):
class SummaryRowInfo:
"""Information about a single summary row"""
+ id: str
function: Literal["min", "max", "mean", "median"]
values: dict[str, str | int | float] # TODO: consider datatype
side: Literal["top", "bottom"]
@@ -989,23 +990,74 @@ class SummaryRowInfo:
class SummaryRows(_Sequence[SummaryRowInfo]):
- """A sequence of summary rows"""
+ """A sequence of summary rows
+
+ The following strctures should always be true about summary rows:
+ - The id is also the label (often the same as the function name)
+ - There is at most 1 row for each group and id pairing
+ - If a summary row is added and no row exists for that group and id, add it
+ - If a summary row is added and a row exists for that group and id pairing,
+ then replace all cells (in values) that are numeric in the new version
+ """
_d: list[SummaryRowInfo]
def __init__(self, rows: list[SummaryRowInfo] | None = None):
- if rows is None:
- rows = []
- self._d = rows
+ self._d = []
+ if rows is not None:
+ for row in rows:
+ self.add_summary_row(row)
+
+ def add_summary_row(self, summary_row: SummaryRowInfo) -> None:
+ """Add a summary row following the merging rules in the class docstring."""
+ # Find existing row with same group and id
+ existing_index = None
+ for i, existing_row in enumerate(self._d):
+ if (
+ existing_row.group.group_id == summary_row.group.group_id
+ and existing_row.id == summary_row.id
+ ):
+ existing_index = i
+ break
+
+ new_rows = self._d.copy()
+
+ if existing_index is None:
+ # No existing row for this group and id, add it
+ new_rows.append(summary_row)
+ else:
+ # Replace existing row, but merge numeric values from new version
+ existing_row = self._d[existing_index]
+
+ # Start with existing values
+ merged_values = existing_row.values.copy()
+
+ # Replace with numeric values from new row
+ for key, new_value in summary_row.values.items():
+ if isinstance(new_value, (int, float)):
+ merged_values[key] = new_value
+
+ # Create merged row with new row's properties but merged values
+ merged_row = SummaryRowInfo(
+ id=summary_row.id,
+ function=summary_row.function,
+ values=merged_values,
+ side=summary_row.side,
+ group=summary_row.group,
+ )
+
+ new_rows[existing_index] = merged_row
+
+ self._d = new_rows
+
+ return
- def summary_rows_dict(self) -> dict[str, list[SummaryRowInfo]]:
- """Get dictionary mapping group_id to list of summary rows for that group"""
- result = {}
+ def get_summary_rows_group(self, group_id: str) -> list[SummaryRowInfo]:
+ """Get list of summary rows for that group"""
+ result: list[SummaryRowInfo] = []
for summary_row in self._d:
- group_id = summary_row.group.group_id
- if group_id not in result:
- result[group_id] = []
- result[group_id].append(summary_row)
+ if group_id == summary_row.group.group_id:
+ result += [summary_row] # is it better to append?
return result
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index cec5d7074..8ae9a49ad 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -12,7 +12,6 @@
RowGroups,
Styles,
SummaryRowInfo,
- SummaryRows,
)
from ._tbl_data import (
SelectExpr,
@@ -212,27 +211,22 @@ def grand_summary_rows(
if isinstance(fns, str):
fns = [fns]
- # Compute summary rows immediately
- summary_row_infos = []
for fn_name in fns:
row_values_dict = _calculate_summary_row(
self, fn_name, columns, missing_text, group_id=None
)
- # TODO: minimize to one new df function, don't need insert row elsewhere.
-
summary_row_info = SummaryRowInfo(
+ id=fn_name,
function=fn_name,
values=row_values_dict, # TODO: revisit type
side=side,
group=GRAND_SUMMARY_GROUP,
)
- summary_row_infos.append(summary_row_info) # There is probably a better way to do this
- existing_rows = self._summary_rows._d if self._summary_rows is not None else []
- new_summary_rows = SummaryRows(existing_rows + summary_row_infos)
+ self._summary_rows.add_summary_row(summary_row_info)
- return self._replace(_summary_rows=new_summary_rows)
+ return self
def _calculate_summary_row(
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index fa7f62c25..61e88f93a 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -469,8 +469,7 @@ def create_body_component_h(data: GTData) -> str:
body_rows: list[str] = []
# Load summary rows
- summary_rows_dict = data._summary_rows.summary_rows_dict()
- grand_summary_rows = summary_rows_dict.get(GRAND_SUMMARY_GROUP.group_id, [])
+ grand_summary_rows = data._summary_rows.get_summary_rows_group(GRAND_SUMMARY_GROUP.group_id)
# Add grand summary rows at top
top_summary_rows = [row for row in grand_summary_rows if row.side == "top"]
@@ -607,18 +606,12 @@ def _create_row_component_h(
cell_alignment = colinfo.defaulted_align
# Get styles
- if is_summary_row:
- _body_styles = styles_cells # Already filtered to grand summary styles
- _rowname_styles = (
- styles_labels if is_stub_cell else []
- ) # Already filtered to grand summary label styles
- else:
- _body_styles = [
- x for x in styles_cells if x.rownum == row_index and x.colname == colinfo.var
- ]
- _rowname_styles = (
- [x for x in styles_labels if x.rownum == row_index] if is_stub_cell else []
- )
+ _body_styles = [
+ x for x in styles_cells if x.rownum == row_index and x.colname == colinfo.var
+ ]
+ _rowname_styles = (
+ [x for x in styles_labels if x.rownum == row_index] if is_stub_cell else []
+ )
# Build classes and element
if is_stub_cell:
From 801bd993c791bd83482372726bf102fb9853944e Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 16:06:58 -0400
Subject: [PATCH 26/54] rename for clairity
---
great_tables/_gt_data.py | 9 +++++----
great_tables/_utils_render_html.py | 15 +++++++--------
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index d32f9fc23..f9bd70f7a 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -1042,8 +1042,9 @@ def add_summary_row(self, summary_row: SummaryRowInfo) -> None:
id=summary_row.id,
function=summary_row.function,
values=merged_values,
- side=summary_row.side,
- group=summary_row.group,
+ # Setting this to existing row instead of summary_row means original side is fixed
+ side=existing_row.side,
+ group=existing_row.group,
)
new_rows[existing_index] = merged_row
@@ -1052,11 +1053,11 @@ def add_summary_row(self, summary_row: SummaryRowInfo) -> None:
return
- def get_summary_rows_group(self, group_id: str) -> list[SummaryRowInfo]:
+ def get_summary_rows(self, group_id: str, side: str) -> list[SummaryRowInfo]:
"""Get list of summary rows for that group"""
result: list[SummaryRowInfo] = []
for summary_row in self._d:
- if group_id == summary_row.group.group_id:
+ if summary_row.group.group_id == group_id and summary_row.side == side:
result += [summary_row] # is it better to append?
return result
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 61e88f93a..f71da0e93 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -468,12 +468,9 @@ def create_body_component_h(data: GTData) -> str:
body_rows: list[str] = []
- # Load summary rows
- grand_summary_rows = data._summary_rows.get_summary_rows_group(GRAND_SUMMARY_GROUP.group_id)
-
# Add grand summary rows at top
- top_summary_rows = [row for row in grand_summary_rows if row.side == "top"]
- for i, summary_row in enumerate(top_summary_rows):
+ top_g_summary_rows = data._summary_rows.get_summary_rows(GRAND_SUMMARY_GROUP.group_id, "top")
+ for i, summary_row in enumerate(top_g_summary_rows):
row_html = _create_row_component_h(
column_vars=column_vars,
stub_var=stub_var,
@@ -483,7 +480,7 @@ def create_body_component_h(data: GTData) -> str:
styles_cells=styles_grand_summary,
styles_labels=styles_grand_summary_label,
summary_row=summary_row,
- css_class="gt_last_grand_summary_row_top" if i == len(top_summary_rows) - 1 else None,
+ css_class="gt_last_grand_summary_row_top" if i == len(top_g_summary_rows) - 1 else None,
)
body_rows.append(row_html)
@@ -544,8 +541,10 @@ def create_body_component_h(data: GTData) -> str:
## if this table has summary rows
# Add grand summary rows at bottom
- bottom_summary_rows = [row for row in grand_summary_rows if row.side == "bottom"]
- for i, summary_row in enumerate(bottom_summary_rows):
+ bottom_g_summary_rows = data._summary_rows.get_summary_rows(
+ GRAND_SUMMARY_GROUP.group_id, "bottom"
+ )
+ for i, summary_row in enumerate(bottom_g_summary_rows):
row_html = _create_row_component_h(
column_vars=column_vars,
stub_var=stub_var,
From e61866868b48a200b88afe5f01d518ed1340fb84 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 15 Aug 2025 16:40:06 -0400
Subject: [PATCH 27/54] experimenting with styles on grandSummaryStub
---
great_tables/_locations.py | 116 ++++++++++++++++++++++++-----
great_tables/_utils_render_html.py | 4 +-
2 files changed, 99 insertions(+), 21 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index f25e514b7..4a8f92b0d 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -11,6 +11,7 @@
# resolve generic, but we need to import at runtime, due to singledispatch looking
# up annotations
from ._gt_data import (
+ GRAND_SUMMARY_GROUP,
ColInfoTypeEnum,
FootnoteInfo,
FootnotePlacement,
@@ -767,6 +768,59 @@ def resolve_cols_i(
# resolving rows ----
+def resolve_summary_rows_i(
+ data: GTData,
+ expr: RowSelectExpr = None,
+ null_means: Literal["everything", "nothing"] = "everything",
+ group_id: str | None = None, # Which group's summary rows to target
+) -> list[tuple[str, int]]:
+ """Return matching summary row numbers and IDs, based on expr"""
+
+ if isinstance(expr, (str, int)):
+ expr: list[str | int] = [expr]
+
+ # Get summary rows for the specified group
+ if group_id is None:
+ from ._gt_data import GRAND_SUMMARY_GROUP
+
+ group_id = GRAND_SUMMARY_GROUP.group_id
+
+ # Get summary rows for this group
+ summary_rows = [row for row in data._summary_rows._d if row.group.group_id == group_id]
+
+ # Extract row IDs (these become the rownames for the stub)
+ row_ids = [row.id for row in summary_rows]
+
+ if expr is None:
+ if null_means == "everything":
+ return [(row_id, ii) for ii, row_id in enumerate(row_ids)]
+ else:
+ return []
+
+ elif isinstance(expr, list):
+ # Match by function name (id) or by index
+ target_names = set(x for x in expr if isinstance(x, str))
+ target_pos = set(
+ indx if indx >= 0 else len(row_ids) + indx for indx in expr if isinstance(indx, int)
+ )
+
+ selected = [
+ (row_id, ii)
+ for ii, row_id in enumerate(row_ids)
+ if (row_id in target_names or ii in target_pos)
+ ]
+ return selected
+
+ # For summary rows, polars expressions and callables don't make sense
+ # since we're not operating on the main DataFrame
+ raise NotImplementedError(
+ "Summary row selection currently supports:\n\n"
+ " * a list of function names (strings)\n"
+ " * a list of integers (row indices)\n"
+ " * None (for all summary rows)"
+ )
+
+
def resolve_rows_i(
data: GTData | list[str],
expr: RowSelectExpr = None,
@@ -924,16 +978,28 @@ def _(loc: LocRowGroups, data: GTData) -> set[str]:
return group_pos
-@resolve.register(LocStub)
-@resolve.register(LocSummaryStub)
-@resolve.register(LocGrandSummaryStub)
-def _(loc: (LocStub | LocSummaryStub | LocGrandSummaryStub), data: GTData) -> set[int]:
+@resolve.register
+def _(loc: LocStub, data: GTData) -> set[int]:
# TODO: what are the rules for matching row groups?
rows = resolve_rows_i(data=data, expr=loc.rows)
cell_pos = set(row[1] for row in rows)
return cell_pos
+@resolve.register
+def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
+ # Use the specialized function for summary rows
+ rows = resolve_summary_rows_i(data=data, expr=loc.rows, group_id=GRAND_SUMMARY_GROUP.group_id)
+
+ # Return the indices
+ cell_pos = set(row[1] for row in rows)
+ print("cc", cell_pos)
+ return cell_pos
+
+
+# @resolve.register(LocSummaryStub)
+
+
@resolve.register(LocBody)
@resolve.register(LocSummary)
@resolve.register(LocGrandSummary)
@@ -1059,14 +1125,8 @@ def _(loc: LocRowGroups, data: GTData, style: list[CellStyle]) -> GTData:
)
-@set_style.register(LocStub)
-@set_style.register(LocSummaryStub)
-@set_style.register(LocGrandSummaryStub)
-def _(
- loc: (LocStub | LocSummaryStub | LocGrandSummaryStub),
- data: GTData,
- style: list[CellStyle],
-) -> GTData:
+@set_style.register
+def _(loc: LocStub, data: GTData, style: list[CellStyle]) -> GTData:
# validate ----
for entry in style:
entry._raise_if_requires_data(loc)
@@ -1077,14 +1137,26 @@ def _(
return data._replace(_styles=data._styles + new_styles)
-@set_style.register(LocBody)
-@set_style.register(LocSummary)
-@set_style.register(LocGrandSummary)
-def _(
- loc: (LocBody | LocSummary | LocGrandSummary),
- data: GTData,
- style: list[CellStyle],
-) -> GTData:
+@set_style.register
+def _(loc: LocGrandSummaryStub, data: GTData, style: list[CellStyle]) -> GTData:
+ # validate ----
+ for entry in style:
+ entry._raise_if_requires_data(loc)
+
+ # Resolve grand summary stub cells
+ cells = resolve(loc, data)
+
+ # Create StyleInfo entries for each resolved summary row
+ new_styles = [StyleInfo(locname=loc, rownum=rownum, styles=style) for rownum in cells]
+ print("settign new styles, ", new_styles)
+ return data._replace(_styles=data._styles + new_styles)
+
+
+# @set_style.register(LocSummaryStub)
+
+
+@set_style.register
+def _(loc: LocBody, data: GTData, style: list[CellStyle]) -> GTData:
positions: list[CellPos] = resolve(loc, data)
# evaluate any column expressions in styles
@@ -1101,6 +1173,10 @@ def _(
return data._replace(_styles=data._styles + all_info)
+# @set_style.register(LocSummary)
+# @set_style.register(LocGrandSummary)
+
+
# Set footnote generic =================================================================
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index f71da0e93..2c281ba77 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -479,6 +479,7 @@ def create_body_component_h(data: GTData) -> str:
apply_body_striping=False, # No striping for summary rows
styles_cells=styles_grand_summary,
styles_labels=styles_grand_summary_label,
+ row_index=i,
summary_row=summary_row,
css_class="gt_last_grand_summary_row_top" if i == len(top_g_summary_rows) - 1 else None,
)
@@ -553,6 +554,7 @@ def create_body_component_h(data: GTData) -> str:
apply_body_striping=False, # No striping for summary rows
styles_cells=styles_grand_summary,
styles_labels=styles_grand_summary_label,
+ row_index=i,
summary_row=summary_row,
css_class="gt_first_grand_summary_row_bottom" if i == 0 else None,
)
@@ -587,7 +589,7 @@ def _create_row_component_h(
# Get cell content
if is_summary_row:
if colinfo == stub_var:
- cell_content = summary_row.function.capitalize()
+ cell_content = summary_row.id
else:
# hopefully don't need fallback
cell_content = summary_row.values.get(colinfo.var)
From 52e125a22995a7fbc1acdcea7be3a027016bcd1e Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 11:37:05 -0400
Subject: [PATCH 28/54] remove prints
---
great_tables/_locations.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 4a8f92b0d..06beaec3e 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -993,7 +993,6 @@ def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
# Return the indices
cell_pos = set(row[1] for row in rows)
- print("cc", cell_pos)
return cell_pos
@@ -1148,7 +1147,7 @@ def _(loc: LocGrandSummaryStub, data: GTData, style: list[CellStyle]) -> GTData:
# Create StyleInfo entries for each resolved summary row
new_styles = [StyleInfo(locname=loc, rownum=rownum, styles=style) for rownum in cells]
- print("settign new styles, ", new_styles)
+
return data._replace(_styles=data._styles + new_styles)
From 2c644192a750845984f6dc16c11626c55bffd225 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 11:48:59 -0400
Subject: [PATCH 29/54] Add stub column when none exists for summary rows (this
approach does not involve boxhead)
---
great_tables/_gt_data.py | 24 ++++++++++++-------
great_tables/_utils_render_html.py | 37 ++++++++++++++++++++---------
great_tables/_utils_render_latex.py | 4 +++-
3 files changed, 44 insertions(+), 21 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index f9bd70f7a..2ff769848 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -512,10 +512,12 @@ def _get_number_of_visible_data_columns(self) -> int:
# Obtain the number of visible columns in the built table; this should
# account for the size of the stub in the final, built table
- def _get_effective_number_of_columns(self, stub: Stub, options: Options) -> int:
+ def _get_effective_number_of_columns(
+ self, stub: Stub, summary_rows: SummaryRows, options: Options
+ ) -> int:
n_data_cols = self._get_number_of_visible_data_columns()
- stub_layout = stub._get_stub_layout(options=options)
+ stub_layout = stub._get_stub_layout(summary_rows=summary_rows, options=options)
# Once the stub is defined in the package, we need to account
# for the width of the stub at build time to fully obtain the number
# of visible columns in the built table
@@ -676,7 +678,7 @@ def _stub_group_names_has_column(self, options: Options) -> bool:
return row_group_as_column
- def _get_stub_layout(self, options: Options) -> list[str]:
+ def _get_stub_layout(self, summary_rows: SummaryRows, options: Options) -> list[str]:
# Determine which stub components are potentially present as columns
stub_rownames_is_column = "row_id" in self._get_stub_components()
stub_groupnames_is_column = self._stub_group_names_has_column(options=options)
@@ -687,13 +689,14 @@ def _get_stub_layout(self, options: Options) -> list[str]:
# Resolve the layout of the stub (i.e., the roles of columns if present)
if n_stub_cols == 0:
# TODO: If summary rows are present, we will use the `rowname` column
- # # for the summary row labels
- # if _summary_exists(data=data):
- # stub_layout = ["rowname"]
- # else:
- # stub_layout = []
+ # for the summary row labels
+ print(summary_rows)
+ if summary_rows._has_summary_rows():
+ stub_layout = ["rowname"]
+ else:
+ stub_layout = []
- stub_layout = []
+ # stub_layout = []
else:
stub_layout = [
@@ -1061,6 +1064,9 @@ def get_summary_rows(self, group_id: str, side: str) -> list[SummaryRowInfo]:
result += [summary_row] # is it better to append?
return result
+ def _has_summary_rows(self) -> bool:
+ return len(self._d) > 0
+
# Options ----
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index e6e5d2f84..09ec09629 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -9,6 +9,7 @@
from ._gt_data import (
GRAND_SUMMARY_GROUP,
ColInfo,
+ ColInfoTypeEnum,
GroupRowInfo,
GTData,
StyleInfo,
@@ -85,7 +86,7 @@ def create_heading_component_h(data: GTData) -> str:
# Get the effective number of columns, which is number of columns
# that will finally be rendered accounting for the stub layout
n_cols_total = data._boxhead._get_effective_number_of_columns(
- stub=data._stub, options=data._options
+ stub=data._stub, summary_rows=data._summary_rows, options=data._options
)
if has_subtitle:
@@ -125,7 +126,9 @@ def create_columns_component_h(data: GTData) -> str:
# body = data._body
# Get vector representation of stub layout
- stub_layout = data._stub._get_stub_layout(options=data._options)
+ stub_layout = data._stub._get_stub_layout(
+ summary_rows=data._summary_rows, options=data._options
+ )
# Determine the finalized number of spanner rows
spanner_row_count = _get_spanners_matrix_height(data=data, omit_columns_row=True)
@@ -450,15 +453,25 @@ def create_body_component_h(data: GTData) -> str:
row_stub_var = data._boxhead._get_stub_column()
- stub_layout = data._stub._get_stub_layout(options=data._options)
+ stub_layout = data._stub._get_stub_layout(
+ summary_rows=data._summary_rows, options=data._options
+ )
has_row_stub_column = "rowname" in stub_layout
has_group_stub_column = "group_label" in stub_layout
has_groups = data._stub.group_ids is not None and len(data._stub.group_ids) > 0
# If there is a stub, then prepend that to the `column_vars` list
- if row_stub_var is not None:
- column_vars = [row_stub_var] + column_vars
+ if has_row_stub_column:
+ # There is already a column assigned to the rownames
+ if row_stub_var:
+ column_vars = [row_stub_var] + column_vars
+ # Else we have summary rows but no stub yet
+ else:
+ summary_row_stub_var = ColInfo(
+ "__summary_row__", ColInfoTypeEnum.stub, column_align="left"
+ )
+ column_vars = [summary_row_stub_var] + column_vars
# Is the stub to be striped?
table_stub_striped = data._options.row_striping_include_stub.value
@@ -522,7 +535,7 @@ def create_body_component_h(data: GTData) -> str:
# Append a table row for the group heading
else:
colspan_value = data._boxhead._get_effective_number_of_columns(
- stub=data._stub, options=data._options
+ stub=data._stub, summary_rows=data._summary_rows, options=data._options
)
group_class = (
@@ -606,14 +619,16 @@ def _create_row_component_h(
for colinfo in column_vars:
# Get cell content
if is_summary_row:
- if colinfo == stub_var:
+ if colinfo == stub_var or colinfo.type == ColInfoTypeEnum.stub:
cell_content = summary_row.id
else:
- # hopefully don't need fallback
cell_content = summary_row.values.get(colinfo.var)
else:
- cell_content = _get_cell(tbl_data, row_index, colinfo.var)
+ if colinfo.var == "__summary_row__":
+ cell_content = ""
+ else:
+ cell_content = _get_cell(tbl_data, row_index, colinfo.var)
if css_class:
classes = [css_class]
@@ -621,7 +636,7 @@ def _create_row_component_h(
classes = []
cell_str = str(cell_content)
- is_stub_cell = has_stub_column and colinfo.var == stub_var.var
+ is_stub_cell = has_stub_column and stub_var and colinfo.var == stub_var.var
cell_alignment = colinfo.defaulted_align
# Get styles
@@ -676,7 +691,7 @@ def create_source_notes_component_h(data: GTData) -> str:
# Get the effective number of columns, which is number of columns
# that will finally be rendered accounting for the stub layout
n_cols_total = data._boxhead._get_effective_number_of_columns(
- stub=data._stub, options=data._options
+ stub=data._stub, summary_rows=data._summary_rows, options=data._options
)
# Handle the multiline source notes case (each note takes up one line)
diff --git a/great_tables/_utils_render_latex.py b/great_tables/_utils_render_latex.py
index eb8bef7b6..882c81b8f 100644
--- a/great_tables/_utils_render_latex.py
+++ b/great_tables/_utils_render_latex.py
@@ -554,7 +554,9 @@ def _render_as_latex(data: GTData, use_longtable: bool = False, tbl_pos: str | N
_not_implemented("Styles are not yet supported in LaTeX output.")
# Get list representation of stub layout
- stub_layout = data._stub._get_stub_layout(options=data._options)
+ stub_layout = data._stub._get_stub_layout(
+ summary_rows=data._summary_rows, options=data._options
+ )
# Throw exception if a stub is present in the table
if "rowname" in stub_layout or "group_label" in stub_layout:
From d6a5ecb157f7b36e5dc9646d5ca8d2374cf59e2b Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 13:51:03 -0400
Subject: [PATCH 30/54] target location for LocGrandSummaryStub
---
great_tables/_gt_data.py | 21 +++++-----
great_tables/_locations.py | 61 +++---------------------------
great_tables/_utils_render_html.py | 10 +++--
3 files changed, 23 insertions(+), 69 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 2ff769848..d773b839c 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -688,16 +688,13 @@ def _get_stub_layout(self, summary_rows: SummaryRows, options: Options) -> list[
# Resolve the layout of the stub (i.e., the roles of columns if present)
if n_stub_cols == 0:
- # TODO: If summary rows are present, we will use the `rowname` column
+ # If summary rows are present, we will use the `rowname` column
# for the summary row labels
- print(summary_rows)
if summary_rows._has_summary_rows():
stub_layout = ["rowname"]
else:
stub_layout = []
- # stub_layout = []
-
else:
stub_layout = [
label
@@ -988,7 +985,7 @@ class SummaryRowInfo:
id: str
function: Literal["min", "max", "mean", "median"]
values: dict[str, str | int | float] # TODO: consider datatype
- side: Literal["top", "bottom"]
+ side: Literal["top", "bottom"] # TODO: switch to enum
group: GroupRowInfo
@@ -1056,12 +1053,18 @@ def add_summary_row(self, summary_row: SummaryRowInfo) -> None:
return
- def get_summary_rows(self, group_id: str, side: str) -> list[SummaryRowInfo]:
- """Get list of summary rows for that group"""
+ def get_summary_rows(self, group_id: str, side: str | None = None) -> list[SummaryRowInfo]:
+ """Get list of summary rows for that group. If side is None, do not filter by side.
+ Sorts result with 'top' side first, then 'bottom'."""
result: list[SummaryRowInfo] = []
for summary_row in self._d:
- if summary_row.group.group_id == group_id and summary_row.side == side:
- result += [summary_row] # is it better to append?
+ if summary_row.group.group_id == group_id and (
+ side is None or summary_row.side == side
+ ):
+ result.append(summary_row)
+
+ # Sort: 'top' first, then 'bottom'
+ result.sort(key=lambda r: 0 if r.side == "top" else 1) # TODO: modify if enum for side
return result
def _has_summary_rows(self) -> bool:
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 06beaec3e..70e150c10 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -768,59 +768,6 @@ def resolve_cols_i(
# resolving rows ----
-def resolve_summary_rows_i(
- data: GTData,
- expr: RowSelectExpr = None,
- null_means: Literal["everything", "nothing"] = "everything",
- group_id: str | None = None, # Which group's summary rows to target
-) -> list[tuple[str, int]]:
- """Return matching summary row numbers and IDs, based on expr"""
-
- if isinstance(expr, (str, int)):
- expr: list[str | int] = [expr]
-
- # Get summary rows for the specified group
- if group_id is None:
- from ._gt_data import GRAND_SUMMARY_GROUP
-
- group_id = GRAND_SUMMARY_GROUP.group_id
-
- # Get summary rows for this group
- summary_rows = [row for row in data._summary_rows._d if row.group.group_id == group_id]
-
- # Extract row IDs (these become the rownames for the stub)
- row_ids = [row.id for row in summary_rows]
-
- if expr is None:
- if null_means == "everything":
- return [(row_id, ii) for ii, row_id in enumerate(row_ids)]
- else:
- return []
-
- elif isinstance(expr, list):
- # Match by function name (id) or by index
- target_names = set(x for x in expr if isinstance(x, str))
- target_pos = set(
- indx if indx >= 0 else len(row_ids) + indx for indx in expr if isinstance(indx, int)
- )
-
- selected = [
- (row_id, ii)
- for ii, row_id in enumerate(row_ids)
- if (row_id in target_names or ii in target_pos)
- ]
- return selected
-
- # For summary rows, polars expressions and callables don't make sense
- # since we're not operating on the main DataFrame
- raise NotImplementedError(
- "Summary row selection currently supports:\n\n"
- " * a list of function names (strings)\n"
- " * a list of integers (row indices)\n"
- " * None (for all summary rows)"
- )
-
-
def resolve_rows_i(
data: GTData | list[str],
expr: RowSelectExpr = None,
@@ -988,10 +935,12 @@ def _(loc: LocStub, data: GTData) -> set[int]:
@resolve.register
def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
- # Use the specialized function for summary rows
- rows = resolve_summary_rows_i(data=data, expr=loc.rows, group_id=GRAND_SUMMARY_GROUP.group_id)
+ # Select just grand summary rows
+ grand_summary_rows = data._summary_rows.get_summary_rows(GRAND_SUMMARY_GROUP.group_id)
+ grand_summary_rows_ids = [row.id for row in grand_summary_rows]
+
+ rows = resolve_rows_i(data=grand_summary_rows_ids, expr=loc.rows)
- # Return the indices
cell_pos = set(row[1] for row in rows)
return cell_pos
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 09ec09629..ec69397f0 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -576,12 +576,12 @@ def create_body_component_h(data: GTData) -> str:
row_html = _create_row_component_h(
column_vars=column_vars,
stub_var=row_stub_var, # Should probably include group stub?
- has_stub_column=has_row_stub_column, # Should probably include group stub?
+ has_stub_column=has_row_stub_column,
apply_stub_striping=False, # No striping for summary rows
apply_body_striping=False, # No striping for summary rows
styles_cells=styles_grand_summary,
styles_labels=styles_grand_summary_label,
- row_index=i,
+ row_index=i + len(top_g_summary_rows), # Continue indexing from top
summary_row=summary_row,
css_class="gt_first_grand_summary_row_bottom" if i == 0 else None,
)
@@ -603,7 +603,7 @@ def _create_row_component_h(
styles_cells: list[StyleInfo], # Either styles_cells OR styles_grand_summary
styles_labels: list[StyleInfo], # Either styles_row_label OR styles_grand_summary_label
leading_cell: str | None = None, # For group label when row_group_as_column = True
- row_index: int | None = None, # For data rows
+ row_index: int | None = None,
summary_row: SummaryRowInfo | None = None, # For summary rows
tbl_data: TblData | None = None,
css_class: str | None = None,
@@ -636,7 +636,9 @@ def _create_row_component_h(
classes = []
cell_str = str(cell_content)
- is_stub_cell = has_stub_column and stub_var and colinfo.var == stub_var.var
+ is_stub_cell = colinfo.var == "__summary_row__" or (
+ stub_var and colinfo.var == stub_var.var
+ )
cell_alignment = colinfo.defaulted_align
# Get styles
From d906e67ac2115bcecd9625a13dff01f44e0e690c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 14:10:12 -0400
Subject: [PATCH 31/54] style LocGrandSummaryStub and LocGrandSummary
---
great_tables/_locations.py | 82 +++++++++++++++++++++-----------------
1 file changed, 46 insertions(+), 36 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 70e150c10..c3a78739f 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -925,6 +925,22 @@ def _(loc: LocRowGroups, data: GTData) -> set[str]:
return group_pos
+@resolve.register
+def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
+ # Select just grand summary rows
+ grand_summary_rows = data._summary_rows.get_summary_rows(GRAND_SUMMARY_GROUP.group_id)
+ grand_summary_rows_ids = [row.id for row in grand_summary_rows]
+
+ rows = resolve_rows_i(data=grand_summary_rows_ids, expr=loc.rows)
+
+ cell_pos = set(row[1] for row in rows)
+ return cell_pos
+
+
+# @resolve.register(LocSummaryStub)
+# Also target by groupname in styleinfo
+
+
@resolve.register
def _(loc: LocStub, data: GTData) -> set[int]:
# TODO: what are the rules for matching row groups?
@@ -934,24 +950,36 @@ def _(loc: LocStub, data: GTData) -> set[int]:
@resolve.register
-def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
- # Select just grand summary rows
+def _(loc: LocGrandSummary, data: GTData) -> list[CellPos]:
+ if (loc.columns is not None or loc.rows is not None) and loc.mask is not None:
+ raise ValueError(
+ "Cannot specify the `mask` argument along with `columns` or `rows` in `loc.body()`."
+ )
+
grand_summary_rows = data._summary_rows.get_summary_rows(GRAND_SUMMARY_GROUP.group_id)
grand_summary_rows_ids = [row.id for row in grand_summary_rows]
- rows = resolve_rows_i(data=grand_summary_rows_ids, expr=loc.rows)
-
- cell_pos = set(row[1] for row in rows)
+ if loc.mask is None:
+ rows = resolve_rows_i(data=grand_summary_rows_ids, expr=loc.rows)
+ cols = resolve_cols_i(data=data, expr=loc.columns)
+ # TODO: dplyr arranges by `Var1`, and does distinct (since you can tidyselect the same
+ # thing multiple times
+ cell_pos = [
+ CellPos(col[1], row[1], colname=col[0]) for col, row in itertools.product(cols, rows)
+ ]
+ else:
+ # I am not sure how to approach this, since GTData._summary_rows is not a frame
+ # We could convert to a frame, but I don't think that's a simple step
+ raise NotImplementedError("Masked selection is not yet implemented for Grand Summary Rows")
return cell_pos
-# @resolve.register(LocSummaryStub)
+# @resolve.register(LocSummary)
+# Also target by groupname in styleinfo
-@resolve.register(LocBody)
-@resolve.register(LocSummary)
-@resolve.register(LocGrandSummary)
-def _(loc: (LocBody | LocSummary | LocGrandSummary), data: GTData) -> list[CellPos]:
+@resolve.register
+def _(loc: LocBody, data: GTData) -> list[CellPos]:
if (loc.columns is not None or loc.rows is not None) and loc.mask is not None:
raise ValueError(
"Cannot specify the `mask` argument along with `columns` or `rows` in `loc.body()`."
@@ -1073,8 +1101,10 @@ def _(loc: LocRowGroups, data: GTData, style: list[CellStyle]) -> GTData:
)
-@set_style.register
-def _(loc: LocStub, data: GTData, style: list[CellStyle]) -> GTData:
+# @set_style.register(LocSummaryStub)
+@set_style.register(LocStub)
+@set_style.register(LocGrandSummaryStub)
+def _(loc: (LocStub | LocGrandSummaryStub), data: GTData, style: list[CellStyle]) -> GTData:
# validate ----
for entry in style:
entry._raise_if_requires_data(loc)
@@ -1085,26 +1115,10 @@ def _(loc: LocStub, data: GTData, style: list[CellStyle]) -> GTData:
return data._replace(_styles=data._styles + new_styles)
-@set_style.register
-def _(loc: LocGrandSummaryStub, data: GTData, style: list[CellStyle]) -> GTData:
- # validate ----
- for entry in style:
- entry._raise_if_requires_data(loc)
-
- # Resolve grand summary stub cells
- cells = resolve(loc, data)
-
- # Create StyleInfo entries for each resolved summary row
- new_styles = [StyleInfo(locname=loc, rownum=rownum, styles=style) for rownum in cells]
-
- return data._replace(_styles=data._styles + new_styles)
-
-
-# @set_style.register(LocSummaryStub)
-
-
-@set_style.register
-def _(loc: LocBody, data: GTData, style: list[CellStyle]) -> GTData:
+# @set_style.register(LocSummary)
+@set_style.register(LocBody)
+@set_style.register(LocGrandSummary)
+def _(loc: (LocBody | LocGrandSummary), data: GTData, style: list[CellStyle]) -> GTData:
positions: list[CellPos] = resolve(loc, data)
# evaluate any column expressions in styles
@@ -1121,10 +1135,6 @@ def _(loc: LocBody, data: GTData, style: list[CellStyle]) -> GTData:
return data._replace(_styles=data._styles + all_info)
-# @set_style.register(LocSummary)
-# @set_style.register(LocGrandSummary)
-
-
# Set footnote generic =================================================================
From 6672e4af3be683afa4f453ac188b4cccba2bd1d0 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 15:11:51 -0400
Subject: [PATCH 32/54] Handle special cases for summary rows with group stub
columns
---
great_tables/_utils_render_html.py | 65 ++++++++++++++++++++++--------
1 file changed, 49 insertions(+), 16 deletions(-)
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index ec69397f0..9c260bc69 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -486,8 +486,9 @@ def create_body_component_h(data: GTData) -> str:
for i, summary_row in enumerate(top_g_summary_rows):
row_html = _create_row_component_h(
column_vars=column_vars,
- stub_var=row_stub_var, # Should probably include group stub?
- has_stub_column=has_row_stub_column, # Should probably include group stub?
+ row_stub_var=row_stub_var, # Should probably include group stub?
+ has_row_stub_column=has_row_stub_column, # Should probably include group stub?
+ has_group_stub_column=has_group_stub_column, # Add this parameter
apply_stub_striping=False, # No striping for summary rows
apply_body_striping=False, # No striping for summary rows
styles_cells=styles_grand_summary,
@@ -551,8 +552,9 @@ def create_body_component_h(data: GTData) -> str:
# Create data row
row_html = _create_row_component_h(
column_vars=column_vars,
- stub_var=row_stub_var,
- has_stub_column=has_row_stub_column,
+ row_stub_var=row_stub_var,
+ has_row_stub_column=has_row_stub_column,
+ has_group_stub_column=has_group_stub_column,
leading_cell=leading_cell,
apply_stub_striping=table_stub_striped and odd_j_row,
apply_body_striping=table_body_striped and odd_j_row,
@@ -575,13 +577,14 @@ def create_body_component_h(data: GTData) -> str:
for i, summary_row in enumerate(bottom_g_summary_rows):
row_html = _create_row_component_h(
column_vars=column_vars,
- stub_var=row_stub_var, # Should probably include group stub?
- has_stub_column=has_row_stub_column,
- apply_stub_striping=False, # No striping for summary rows
- apply_body_striping=False, # No striping for summary rows
+ row_stub_var=row_stub_var,
+ has_row_stub_column=has_row_stub_column,
+ has_group_stub_column=has_group_stub_column, # Add this parameter
+ apply_stub_striping=False,
+ apply_body_striping=False,
styles_cells=styles_grand_summary,
styles_labels=styles_grand_summary_label,
- row_index=i + len(top_g_summary_rows), # Continue indexing from top
+ row_index=i + len(top_g_summary_rows),
summary_row=summary_row,
css_class="gt_first_grand_summary_row_bottom" if i == 0 else None,
)
@@ -596,8 +599,9 @@ def create_body_component_h(data: GTData) -> str:
def _create_row_component_h(
column_vars: list[ColInfo],
- stub_var: ColInfo | None,
- has_stub_column: bool,
+ row_stub_var: ColInfo | None,
+ has_row_stub_column: bool,
+ has_group_stub_column: bool,
apply_stub_striping: bool,
apply_body_striping: bool,
styles_cells: list[StyleInfo], # Either styles_cells OR styles_grand_summary
@@ -616,17 +620,46 @@ def _create_row_component_h(
if leading_cell:
body_cells.append(leading_cell)
- for colinfo in column_vars:
+ # Handle special cases for summary rows with group stub columns
+ if is_summary_row and has_group_stub_column:
+ if has_row_stub_column:
+ # Case 1: Both row_stub_column and group_stub_column
+ # Create a single cell that spans both columns for summary row label (id)
+ colspan = 2
+ else:
+ # Case 2: Only group_stub_column, no row_stub_column
+ colspan = 1
+
+ cell_styles = _flatten_styles(
+ [x for x in styles_labels if x.rownum == row_index], wrap=True
+ )
+
+ classes = ["gt_row", "gt_left", "gt_stub", "gt_grand_summary_row"]
+ if css_class:
+ classes.append(css_class)
+ classes_str = " ".join(classes)
+
+ body_cells.append(
+ f""" {summary_row.id} | """
+ )
+
+ # Skip the first column in column_vars since we've already handled the stub
+ column_vars_to_process = [column for column in column_vars if not column.is_stub]
+
+ else:
+ # Normal case: process all column_vars
+ column_vars_to_process = column_vars
+
+ for colinfo in column_vars_to_process:
# Get cell content
if is_summary_row:
- if colinfo == stub_var or colinfo.type == ColInfoTypeEnum.stub:
+ if colinfo == row_stub_var or colinfo.type == ColInfoTypeEnum.stub:
cell_content = summary_row.id
else:
cell_content = summary_row.values.get(colinfo.var)
-
else:
if colinfo.var == "__summary_row__":
- cell_content = ""
+ cell_content = " "
else:
cell_content = _get_cell(tbl_data, row_index, colinfo.var)
@@ -637,7 +670,7 @@ def _create_row_component_h(
cell_str = str(cell_content)
is_stub_cell = colinfo.var == "__summary_row__" or (
- stub_var and colinfo.var == stub_var.var
+ row_stub_var and colinfo.var == row_stub_var.var
)
cell_alignment = colinfo.defaulted_align
From e4d038c1ab85b5af2c3fc3fe24e84ebb2785669c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 15:50:14 -0400
Subject: [PATCH 33/54] locations docstrings
---
great_tables/_locations.py | 118 ++++++++++++++++++++++++++++++++++---
1 file changed, 109 insertions(+), 9 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index c3a78739f..bf1d562f6 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -481,13 +481,62 @@ class LocRowGroups(Loc):
rows: RowSelectExpr = None
-@dataclass
-class LocSummaryStub(Loc):
- rows: RowSelectExpr = None
+# @dataclass
+# class LocSummaryStub(Loc):
+# rows: RowSelectExpr = None
@dataclass
class LocGrandSummaryStub(Loc):
+ """Target the grand summary stub.
+
+ With `loc.grand_summary_stub()` we can target the cells containing the grand summary row labels,
+ which reside in the table stub. This is useful for applying custom styling with the
+ [`tab_style()`](`great_tables.GT.tab_style`) method. That method has a `locations=` argument and
+ this class should be used there to perform the targeting.
+
+ Parameters
+ ----------
+ rows
+ The rows to target within the grand summary stub. Can either be a single row name or a
+ series of row names provided in a list. If no rows are specified, all rows are targeted.
+ Note that if rows are targeted by index, top and bottom grand summary rows are indexed as
+ one combined list starting with the top.
+
+ Returns
+ -------
+ LocGrandSummaryStub
+ A LocGrandSummaryStub object, which is used for a `locations=` argument if specifying the
+ table's grand summary rows' labels.
+
+ Examples
+ --------
+ Let's use a subset of the `gtcars` dataset in a new table. We will style the entire table grand
+ summary stub (the row labels) by using `locations=loc.grand_summary_stub()` within
+ [`tab_style()`](`great_tables.GT.tab_style`).
+
+ ```{python}
+ from great_tables import GT, style, loc
+ from great_tables.data import gtcars
+
+ (
+ GT(
+ gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(5),
+ rowname_col="model",
+ groupname_col="mfr",
+ )
+ .tab_options(row_group_as_column=True)
+ .grand_summary_rows(fns=["min", "max"], side="top")
+ .grand_summary_rows(fns="mean", side="bottom")
+ .tab_style(
+ style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
+ locations=loc.grand_summary_stub(),
+ )
+ .fmt_integer(columns=["hp", "trq", "mpg_c"])
+ )
+ ```
+ """
+
rows: RowSelectExpr = None
@@ -556,16 +605,67 @@ class LocBody(Loc):
mask: PlExpr | None = None
-@dataclass
-class LocSummary(Loc):
- # TODO: these can be tidyselectors
- columns: SelectExpr = None
- rows: RowSelectExpr = None
- mask: PlExpr | None = None
+# @dataclass
+# class LocSummary(Loc):
+# # TODO: these can be tidyselectors
+# columns: SelectExpr = None
+# rows: RowSelectExpr = None
+# mask: PlExpr | None = None
@dataclass
class LocGrandSummary(Loc):
+ """Target the data cells in grand summary rows.
+
+ With `loc.grand_summary()` we can target the cells containing the grand summary data.
+ This is useful for applying custom styling with the [`tab_style()`](`great_tables.GT.tab_style`)
+ method. That method has a `locations=` argument and this class should be used there to perform
+ the targeting.
+
+ Parameters
+ ----------
+ columns
+ The columns to target. Can either be a single column name or a series of column names
+ provided in a list.
+ rows
+ The rows to target. Can either be a single row name or a series of row names provided in a
+ list. Note that if rows are targeted by index, top and bottom grand summary rows are indexed
+ as one combined list starting with the top.
+
+ Returns
+ -------
+ LocGrandSummary
+ A LocGrandSummary object, which is used for a `locations=` argument if specifying the
+ table's grand summary rows.
+
+ Examples
+ --------
+ Let's use a subset of the `gtcars` dataset in a new table. We will style all of the grand
+ summary cells by using `locations=loc.grand_summary()` within
+ [`tab_style()`](`great_tables.GT.tab_style`).
+
+ ```{python}
+ from great_tables import GT, style, loc
+ from great_tables.data import gtcars
+
+ (
+ GT(
+ gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(5),
+ rowname_col="model",
+ groupname_col="mfr",
+ )
+ .tab_options(row_group_as_column=True)
+ .grand_summary_rows(fns=["min", "max"], side="top")
+ .grand_summary_rows(fns="mean", side="bottom")
+ .tab_style(
+ style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
+ locations=loc.grand_summary(),
+ )
+ .fmt_integer(columns=["hp", "trq", "mpg_c"])
+ )
+ ```
+ """
+
# TODO: these can be tidyselectors
columns: SelectExpr = None
rows: RowSelectExpr = None
From 3302cc47d739d85bb7c6c2f9a0019268912363d5 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 15:54:07 -0400
Subject: [PATCH 34/54] adding new locs to quarto
---
docs/_quarto.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index f21c42f58..3f3fbb3bb 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -179,8 +179,10 @@ quartodoc:
- loc.column_header
- loc.spanner_labels
- loc.column_labels
+ - loc.grand_summary_stub
- loc.stub
- loc.row_groups
+ - loc.grand_summary
- loc.body
- loc.footer
- loc.source_notes
From 5a88e853bc0ed8e332c6da3d744608875fe2e9aa Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 19 Aug 2025 17:07:19 -0400
Subject: [PATCH 35/54] more documentaiton
---
docs/_quarto.yml | 7 ++++++
docs/get-started/targeted-styles.qmd | 7 +++++-
great_tables/_modify_rows.py | 34 +++++++++++++++++++++++++++-
3 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index 3f3fbb3bb..a0aee675e 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -165,6 +165,13 @@ quartodoc:
- GT.cols_move_to_end
- GT.cols_hide
- GT.cols_unhide
+ - title: Adding rows
+ desc: >
+ The [`grand_summary_rows()`](`great_tables.GT.grand_summary_rows`) function will add rows to
+ summarize data in your table, such as totals or averages.
+ contents:
+ - GT.grand_summary_rows
+
- title: Location Targeting and Styling Classes
desc: >
Location targeting is a powerful feature of **Great Tables**. It allows for the precise
diff --git a/docs/get-started/targeted-styles.qmd b/docs/get-started/targeted-styles.qmd
index eb3cf6239..a741383df 100644
--- a/docs/get-started/targeted-styles.qmd
+++ b/docs/get-started/targeted-styles.qmd
@@ -18,7 +18,7 @@ Below is a big example that shows all possible `loc` specifiers being used.
```{python}
from great_tables import GT, exibble, loc, style
-# https://colorbrewer2.org/#type=qualitative&scheme=Paired&n=12
+# https://colorbrewer2.org/#type=qualitative&scheme=Paired&n=12 and grey
brewer_colors = [
"#a6cee3",
"#1f78b4",
@@ -32,6 +32,7 @@ brewer_colors = [
"#6a3d9a",
"#ffff99",
"#b15928",
+ "#808080",
]
c = iter(brewer_colors)
@@ -43,6 +44,7 @@ gt = (
.tab_source_note("yo")
.tab_spanner("spanner", ["char", "fctr"])
.tab_stubhead("stubhead")
+ .grand_summary_rows(fns="sum", columns="num")
)
(
@@ -64,6 +66,9 @@ gt = (
.tab_style(style.borders(weight="3px"), loc.stub(rows=1))
.tab_style(style.fill(next(c)), loc.stub())
.tab_style(style.fill(next(c)), loc.stubhead())
+ # Summary Rows --------------
+ .tab_style(style.fill(next(c)), loc.grand_summary())
+ .tab_style(style.fill(next(c)), loc.grand_summary_stub())
)
```
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 8ae9a49ad..69567d1ab 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -204,7 +204,39 @@ def grand_summary_rows(
) -> GTSelf:
"""Add grand summary rows to the table.
- TODO docstring
+ Add grand summary rows by using the table data and any suitable aggregation functions. With
+ grand summary rows, all of the available data in the gt table is incorporated (regardless of
+ whether some of the data are part of row groups). Multiple grand summary rows can be added via
+ expressions given to fns. You can selectively format the values in the resulting grand summary
+ cells by use of formatting expressions in fmt.
+
+ Parameters
+ ----------
+
+ fns
+ TODO text
+ columns
+ The columns to target. Can either be a single column name or a series of column names
+ provided in a list.
+ side
+ Should the grand summary rows be placed at the `"bottom"` (the default) or the `"top"` of
+ the table?
+ missing_text
+ The text to be used in summary cells with no data outputs.
+
+ Returns
+ -------
+ GT
+ The GT object is returned. This is the same object that the method is called on so that we
+ can facilitate method chaining.
+
+ Examples
+ --------
+ TODO Explanation
+ ```{python}
+
+ ```
+
"""
# Computes summary rows immediately but stores them separately from main data.
From a06cd13b43600069296fc20748300a135988dbc5 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 20 Aug 2025 11:50:56 -0400
Subject: [PATCH 36/54] accept summaryFn and label
---
great_tables/_gt_data.py | 6 ++-
great_tables/_modify_rows.py | 89 ++++++++++++++++++++++++++----------
2 files changed, 69 insertions(+), 26 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index d773b839c..7b20a94a9 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -977,13 +977,15 @@ def __init__(self, func: FormatFns, cols: list[str], rows: list[int]):
# Summary Rows ---
GRAND_SUMMARY_GROUP = GroupRowInfo(group_id="__grand_summary_group__")
+SummaryFn = Callable[..., Any]
+
@dataclass(frozen=True)
class SummaryRowInfo:
"""Information about a single summary row"""
id: str
- function: Literal["min", "max", "mean", "median"]
+ label: str # For now, label and id are identical
values: dict[str, str | int | float] # TODO: consider datatype
side: Literal["top", "bottom"] # TODO: switch to enum
group: GroupRowInfo
@@ -1040,7 +1042,7 @@ def add_summary_row(self, summary_row: SummaryRowInfo) -> None:
# Create merged row with new row's properties but merged values
merged_row = SummaryRowInfo(
id=summary_row.id,
- function=summary_row.function,
+ label=summary_row.label,
values=merged_values,
# Setting this to existing row instead of summary_row means original side is fixed
side=existing_row.side,
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 69567d1ab..2fd9a8471 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -7,10 +7,12 @@
from ._gt_data import (
GRAND_SUMMARY_GROUP,
+ FormatFn,
GTData,
Locale,
RowGroups,
Styles,
+ SummaryFn,
SummaryRowInfo,
)
from ._tbl_data import (
@@ -196,8 +198,8 @@ def with_id(self: GTSelf, id: str | None = None) -> GTSelf:
def grand_summary_rows(
self: GTSelf,
- fns: list[Literal["min", "max", "mean", "median", "sum"]]
- | Literal["min", "max", "mean", "median"],
+ fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
+ fmt: FormatFn | None = None,
columns: SelectExpr = None,
side: Literal["bottom", "top"] = "bottom",
missing_text: str = "---",
@@ -215,6 +217,8 @@ def grand_summary_rows(
fns
TODO text
+ fmt
+ TODO text
columns
The columns to target. Can either be a single column name or a series of column names
provided in a list.
@@ -239,18 +243,16 @@ def grand_summary_rows(
"""
# Computes summary rows immediately but stores them separately from main data.
+ normalized_fns = _normalize_fns_to_tuples(fns)
- if isinstance(fns, str):
- fns = [fns]
-
- for fn_name in fns:
+ for label, fn_callable in normalized_fns:
row_values_dict = _calculate_summary_row(
- self, fn_name, columns, missing_text, group_id=None
+ self, fn_callable, columns, missing_text, group_id=None
)
summary_row_info = SummaryRowInfo(
- id=fn_name,
- function=fn_name,
+ id=label,
+ label=label,
values=row_values_dict, # TODO: revisit type
side=side,
group=GRAND_SUMMARY_GROUP,
@@ -261,14 +263,65 @@ def grand_summary_rows(
return self
+def _normalize_fns_to_tuples(
+ fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
+) -> list[tuple[str, SummaryFn]]:
+ """Convert all fns formats to a list of (label, callable) tuples."""
+
+ # Case 1: Single string -> convert to list
+ if isinstance(fns, str):
+ fns = [fns]
+
+ # Case 2: List of strings
+ if isinstance(fns, list) and all(isinstance(fn, str) for fn in fns):
+ return [(fn_name, _get_builtin_function(fn_name)) for fn_name in fns]
+
+ # Case 3: List of callables -> infer labels from function names
+ if isinstance(fns, list) and all(callable(fn) for fn in fns):
+ return [(fn.__name__, fn) for fn in fns]
+
+ # Case 4: Dict with string values -> convert strings to callables
+ if isinstance(fns, dict) and all(isinstance(v, str) for v in fns.values()):
+ return [(label, _get_builtin_function(fn_name)) for label, fn_name in fns.items()]
+
+ # Case 5: Dict with callable values -> everything is given
+ if isinstance(fns, dict) and all(callable(v) for v in fns.values()):
+ return list(fns.items())
+
+ raise ValueError(f"Unsupported fns format: {type(fns)} or mixed types in collection")
+
+
+def _get_builtin_function(fn_name: str) -> SummaryFn:
+ """Convert string function name to actual callable function."""
+
+ def _mean(values: list[Any]) -> float:
+ return sum(values) / len(values)
+
+ def _median(values: list[Any]) -> Any:
+ return quantiles(values, n=2)[0]
+
+ builtin_functions: dict[str, SummaryFn] = {
+ "min": min,
+ "max": max,
+ "sum": sum,
+ "mean": _mean,
+ "median": _median,
+ }
+
+ if fn_name not in builtin_functions:
+ raise ValueError(f"Unknown function name: {fn_name}")
+
+ return builtin_functions[fn_name]
+
+
def _calculate_summary_row(
data: GTData,
- fn_name: str,
+ fn: SummaryFn,
columns: SelectExpr,
missing_text: str,
group_id: str | None = None, # None means grand summary (all data)
) -> dict[str, Any]:
- """Calculate a summary row based on the function name and selected columns for a specific group."""
+ """Calculate a summary row based on the function and selected columns for a specific group."""
original_columns = data._boxhead._get_columns()
summary_col_names = resolve_cols_c(data=data, expr=columns)
@@ -285,19 +338,7 @@ def _calculate_summary_row(
for col in original_columns:
if col in summary_col_names:
col_data = to_list(data._tbl_data[col])
-
- if fn_name == "min":
- summary_row[col] = min(col_data)
- elif fn_name == "max":
- summary_row[col] = max(col_data)
- elif fn_name == "mean":
- summary_row[col] = sum(col_data) / len(col_data)
- elif fn_name == "median":
- summary_row[col] = quantiles(col_data, n=2)[0] # Consider using the one in nanoplot
- elif fn_name == "sum":
- summary_row[col] = sum(col_data)
- else:
- summary_row[col] = "hi" # Should never get here
+ summary_row[col] = fn(col_data)
else:
summary_row[col] = missing_text
From d8249ecd1dd3038f79cace84540a03c551b21f6e Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 20 Aug 2025 12:17:21 -0400
Subject: [PATCH 37/54] possible approach to fmt in grand Summary Rows, WIP
---
great_tables/_modify_rows.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 2fd9a8471..08e25a641 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -247,7 +247,7 @@ def grand_summary_rows(
for label, fn_callable in normalized_fns:
row_values_dict = _calculate_summary_row(
- self, fn_callable, columns, missing_text, group_id=None
+ self, fn_callable, fmt, columns, missing_text, group_id=None
)
summary_row_info = SummaryRowInfo(
@@ -317,6 +317,7 @@ def _median(values: list[Any]) -> Any:
def _calculate_summary_row(
data: GTData,
fn: SummaryFn,
+ fmt: FormatFn | None,
columns: SelectExpr,
missing_text: str,
group_id: str | None = None, # None means grand summary (all data)
@@ -338,7 +339,14 @@ def _calculate_summary_row(
for col in original_columns:
if col in summary_col_names:
col_data = to_list(data._tbl_data[col])
- summary_row[col] = fn(col_data)
+ res = fn(col_data)
+
+ if fmt is not None:
+ # The vals functions expect a list and return a list
+ formatted_list = fmt([res])
+ res = formatted_list[0]
+
+ summary_row[col] = res
else:
summary_row[col] = missing_text
From 64b1cb0d83ce435a727d0d87eaea598b3f5602ca Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 22 Aug 2025 10:48:31 -0400
Subject: [PATCH 38/54] refactor summary rows to mapping, and add summary rows
grand attribute to gt_data
---
great_tables/_gt_data.py | 138 ++++++++++++++++------------
great_tables/_locations.py | 6 +-
great_tables/_modify_rows.py | 12 ++-
great_tables/_utils_render_html.py | 26 ++++--
great_tables/_utils_render_latex.py | 3 +-
5 files changed, 108 insertions(+), 77 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 7b20a94a9..1392e8e01 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -2,7 +2,7 @@
import copy
import re
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field, replace
from enum import Enum, auto
from itertools import chain, product
@@ -76,6 +76,7 @@ class GTData:
_heading: Heading
_stubhead: Stubhead
_summary_rows: SummaryRows
+ _summary_rows_grand: SummaryRows
_source_notes: SourceNotes
_footnotes: Footnotes
_styles: Styles
@@ -123,7 +124,8 @@ def from_data(
_spanners=Spanners([]),
_heading=Heading(),
_stubhead=None,
- _summary_rows=SummaryRows([]),
+ _summary_rows=SummaryRows(),
+ _summary_rows_grand=SummaryRows(),
_source_notes=[],
_footnotes=[],
_styles=[],
@@ -513,11 +515,11 @@ def _get_number_of_visible_data_columns(self) -> int:
# Obtain the number of visible columns in the built table; this should
# account for the size of the stub in the final, built table
def _get_effective_number_of_columns(
- self, stub: Stub, summary_rows: SummaryRows, options: Options
+ self, stub: Stub, has_summary_rows: bool, options: Options
) -> int:
n_data_cols = self._get_number_of_visible_data_columns()
- stub_layout = stub._get_stub_layout(summary_rows=summary_rows, options=options)
+ stub_layout = stub._get_stub_layout(has_summary_rows=has_summary_rows, options=options)
# Once the stub is defined in the package, we need to account
# for the width of the stub at build time to fully obtain the number
# of visible columns in the built table
@@ -678,7 +680,7 @@ def _stub_group_names_has_column(self, options: Options) -> bool:
return row_group_as_column
- def _get_stub_layout(self, summary_rows: SummaryRows, options: Options) -> list[str]:
+ def _get_stub_layout(self, has_summary_rows: bool, options: Options) -> list[str]:
# Determine which stub components are potentially present as columns
stub_rownames_is_column = "row_id" in self._get_stub_components()
stub_groupnames_is_column = self._stub_group_names_has_column(options=options)
@@ -690,7 +692,7 @@ def _get_stub_layout(self, summary_rows: SummaryRows, options: Options) -> list[
if n_stub_cols == 0:
# If summary rows are present, we will use the `rowname` column
# for the summary row labels
- if summary_rows._has_summary_rows():
+ if has_summary_rows:
stub_layout = ["rowname"]
else:
stub_layout = []
@@ -975,7 +977,10 @@ def __init__(self, func: FormatFns, cols: list[str], rows: list[int]):
# Summary Rows ---
-GRAND_SUMMARY_GROUP = GroupRowInfo(group_id="__grand_summary_group__")
+
+# This can't conflict with actual group ids since we have a
+# seperate data structure for grand summary row infos
+GRAND_SUMMARY_GROUP_ID = "__grand_summary_group__"
SummaryFn = Callable[..., Any]
@@ -986,12 +991,11 @@ class SummaryRowInfo:
id: str
label: str # For now, label and id are identical
- values: dict[str, str | int | float] # TODO: consider datatype
- side: Literal["top", "bottom"] # TODO: switch to enum
- group: GroupRowInfo
+ values: dict[str, str | int | float] # TODO: consider datatype, series?
+ side: Literal["top", "bottom"] # TODO: consider enum
-class SummaryRows(_Sequence[SummaryRowInfo]):
+class SummaryRows(Mapping[str, list[SummaryRowInfo]]):
"""A sequence of summary rows
The following strctures should always be true about summary rows:
@@ -1002,75 +1006,89 @@ class SummaryRows(_Sequence[SummaryRowInfo]):
then replace all cells (in values) that are numeric in the new version
"""
- _d: list[SummaryRowInfo]
+ _d: dict[str, list[SummaryRowInfo]]
+
+ def __init__(self):
+ self._d = {}
- def __init__(self, rows: list[SummaryRowInfo] | None = None):
- self._d = []
- if rows is not None:
- for row in rows:
- self.add_summary_row(row)
+ def __bool__(self) -> bool:
+ """Return True if there are any summary rows, False otherwise."""
+ return len(self._d) > 0
- def add_summary_row(self, summary_row: SummaryRowInfo) -> None:
+ def __getitem__(self, key: str) -> list[SummaryRowInfo]:
+ """Get a summary row by its ID."""
+ return self._d[key]
+
+ def add_summary_row(self, summary_row: SummaryRowInfo, group_id: str) -> None:
"""Add a summary row following the merging rules in the class docstring."""
- # Find existing row with same group and id
- existing_index = None
- for i, existing_row in enumerate(self._d):
- if (
- existing_row.group.group_id == summary_row.group.group_id
- and existing_row.id == summary_row.id
- ):
- existing_index = i
- break
- new_rows = self._d.copy()
+ existing_group = self.get(group_id)
+
+ if not existing_group:
+ self._d[group_id] = [summary_row]
+ return
- if existing_index is None:
- # No existing row for this group and id, add it
- new_rows.append(summary_row)
else:
- # Replace existing row, but merge numeric values from new version
- existing_row = self._d[existing_index]
-
- # Start with existing values
- merged_values = existing_row.values.copy()
-
- # Replace with numeric values from new row
- for key, new_value in summary_row.values.items():
- if isinstance(new_value, (int, float)):
- merged_values[key] = new_value
-
- # Create merged row with new row's properties but merged values
- merged_row = SummaryRowInfo(
- id=summary_row.id,
- label=summary_row.label,
- values=merged_values,
- # Setting this to existing row instead of summary_row means original side is fixed
- side=existing_row.side,
- group=existing_row.group,
- )
+ existing_index = None
+ for i, existing_row in enumerate(existing_group):
+ if existing_row.id == summary_row.id:
+ existing_index = i
+ break
+
+ new_rows = existing_group
+
+ if existing_index is None:
+ # No existing row for this group and id, add it
+ new_rows.append(summary_row)
+ else:
+ # Replace existing row, but merge numeric values from new version
+ existing_row = new_rows[existing_index]
+
+ # Start with existing values
+ merged_values = existing_row.values.copy()
+
+ # Replace with numeric values from new row
+ for key, new_value in summary_row.values.items():
+ if isinstance(new_value, (int, float)):
+ merged_values[key] = new_value
+
+ # Create merged row with new row's properties but merged values
+ merged_row = SummaryRowInfo(
+ id=summary_row.id,
+ label=summary_row.label,
+ values=merged_values,
+ # Setting this to existing row instead of summary_row means original
+ # side is fixed by whatever side is first assigned to this row
+ side=existing_row.side,
+ )
- new_rows[existing_index] = merged_row
+ new_rows[existing_index] = merged_row
- self._d = new_rows
+ self._d[group_id] = new_rows
return
def get_summary_rows(self, group_id: str, side: str | None = None) -> list[SummaryRowInfo]:
"""Get list of summary rows for that group. If side is None, do not filter by side.
Sorts result with 'top' side first, then 'bottom'."""
+
result: list[SummaryRowInfo] = []
- for summary_row in self._d:
- if summary_row.group.group_id == group_id and (
- side is None or summary_row.side == side
- ):
- result.append(summary_row)
+ summary_row_group = self.get(group_id)
+
+ if summary_row_group:
+ for summary_row in summary_row_group:
+ if side is None or summary_row.side == side:
+ result.append(summary_row)
# Sort: 'top' first, then 'bottom'
result.sort(key=lambda r: 0 if r.side == "top" else 1) # TODO: modify if enum for side
return result
- def _has_summary_rows(self) -> bool:
- return len(self._d) > 0
+ def __iter__(self):
+ raise NotImplementedError
+
+ def __len__(self):
+ raise NotImplementedError
# Options ----
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index bf1d562f6..6aefab237 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -11,7 +11,7 @@
# resolve generic, but we need to import at runtime, due to singledispatch looking
# up annotations
from ._gt_data import (
- GRAND_SUMMARY_GROUP,
+ GRAND_SUMMARY_GROUP_ID,
ColInfoTypeEnum,
FootnoteInfo,
FootnotePlacement,
@@ -1028,7 +1028,7 @@ def _(loc: LocRowGroups, data: GTData) -> set[str]:
@resolve.register
def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
# Select just grand summary rows
- grand_summary_rows = data._summary_rows.get_summary_rows(GRAND_SUMMARY_GROUP.group_id)
+ grand_summary_rows = data._summary_rows_grand.get_summary_rows(GRAND_SUMMARY_GROUP_ID)
grand_summary_rows_ids = [row.id for row in grand_summary_rows]
rows = resolve_rows_i(data=grand_summary_rows_ids, expr=loc.rows)
@@ -1056,7 +1056,7 @@ def _(loc: LocGrandSummary, data: GTData) -> list[CellPos]:
"Cannot specify the `mask` argument along with `columns` or `rows` in `loc.body()`."
)
- grand_summary_rows = data._summary_rows.get_summary_rows(GRAND_SUMMARY_GROUP.group_id)
+ grand_summary_rows = data._summary_rows_grand.get_summary_rows(GRAND_SUMMARY_GROUP_ID)
grand_summary_rows_ids = [row.id for row in grand_summary_rows]
if loc.mask is None:
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 08e25a641..e6ca5c645 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -6,7 +6,7 @@
from great_tables._locations import resolve_cols_c
from ._gt_data import (
- GRAND_SUMMARY_GROUP,
+ GRAND_SUMMARY_GROUP_ID,
FormatFn,
GTData,
Locale,
@@ -199,6 +199,7 @@ def with_id(self: GTSelf, id: str | None = None) -> GTSelf:
def grand_summary_rows(
self: GTSelf,
fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
+ # fns: dict[str, Callable[[list], Any]],
fmt: FormatFn | None = None,
columns: SelectExpr = None,
side: Literal["bottom", "top"] = "bottom",
@@ -255,14 +256,17 @@ def grand_summary_rows(
label=label,
values=row_values_dict, # TODO: revisit type
side=side,
- group=GRAND_SUMMARY_GROUP,
)
- self._summary_rows.add_summary_row(summary_row_info)
+ self._summary_rows_grand.add_summary_row(summary_row_info, GRAND_SUMMARY_GROUP_ID)
return self
+# TODO: delegate to group by agg instead
+# TODO: validates after
+
+
def _normalize_fns_to_tuples(
fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
) -> list[tuple[str, SummaryFn]]:
@@ -328,7 +332,7 @@ def _calculate_summary_row(
summary_col_names = resolve_cols_c(data=data, expr=columns)
if group_id is None:
- group_id = GRAND_SUMMARY_GROUP.group_id
+ group_id = GRAND_SUMMARY_GROUP_ID
else:
# Future: group-specific logic would go here
raise NotImplementedError("Group-specific summaries not yet implemented")
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 9c260bc69..8b1c0a9f6 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -7,7 +7,7 @@
from . import _locations as loc
from ._gt_data import (
- GRAND_SUMMARY_GROUP,
+ GRAND_SUMMARY_GROUP_ID,
ColInfo,
ColInfoTypeEnum,
GroupRowInfo,
@@ -83,10 +83,14 @@ def create_heading_component_h(data: GTData) -> str:
title_style = _flatten_styles(styles_header + styles_title, wrap=True)
subtitle_style = _flatten_styles(styles_header + styles_subtitle, wrap=True)
+ has_summary_rows = bool(data._summary_rows or data._summary_rows_grand)
+
# Get the effective number of columns, which is number of columns
# that will finally be rendered accounting for the stub layout
n_cols_total = data._boxhead._get_effective_number_of_columns(
- stub=data._stub, summary_rows=data._summary_rows, options=data._options
+ stub=data._stub,
+ has_summary_rows=has_summary_rows,
+ options=data._options,
)
if has_subtitle:
@@ -126,8 +130,9 @@ def create_columns_component_h(data: GTData) -> str:
# body = data._body
# Get vector representation of stub layout
+ has_summary_rows = bool(data._summary_rows or data._summary_rows_grand)
stub_layout = data._stub._get_stub_layout(
- summary_rows=data._summary_rows, options=data._options
+ has_summary_rows=has_summary_rows, options=data._options
)
# Determine the finalized number of spanner rows
@@ -453,8 +458,9 @@ def create_body_component_h(data: GTData) -> str:
row_stub_var = data._boxhead._get_stub_column()
+ has_summary_rows = bool(data._summary_rows or data._summary_rows_grand)
stub_layout = data._stub._get_stub_layout(
- summary_rows=data._summary_rows, options=data._options
+ has_summary_rows=has_summary_rows, options=data._options
)
has_row_stub_column = "rowname" in stub_layout
@@ -468,6 +474,7 @@ def create_body_component_h(data: GTData) -> str:
column_vars = [row_stub_var] + column_vars
# Else we have summary rows but no stub yet
else:
+ # TODO: this naming is not ideal
summary_row_stub_var = ColInfo(
"__summary_row__", ColInfoTypeEnum.stub, column_align="left"
)
@@ -482,7 +489,7 @@ def create_body_component_h(data: GTData) -> str:
body_rows: list[str] = []
# Add grand summary rows at top
- top_g_summary_rows = data._summary_rows.get_summary_rows(GRAND_SUMMARY_GROUP.group_id, "top")
+ top_g_summary_rows = data._summary_rows_grand.get_summary_rows(GRAND_SUMMARY_GROUP_ID, "top")
for i, summary_row in enumerate(top_g_summary_rows):
row_html = _create_row_component_h(
column_vars=column_vars,
@@ -536,7 +543,7 @@ def create_body_component_h(data: GTData) -> str:
# Append a table row for the group heading
else:
colspan_value = data._boxhead._get_effective_number_of_columns(
- stub=data._stub, summary_rows=data._summary_rows, options=data._options
+ stub=data._stub, has_summary_rows=has_summary_rows, options=data._options
)
group_class = (
@@ -571,8 +578,8 @@ def create_body_component_h(data: GTData) -> str:
## if this table has summary rows
# Add grand summary rows at bottom
- bottom_g_summary_rows = data._summary_rows.get_summary_rows(
- GRAND_SUMMARY_GROUP.group_id, "bottom"
+ bottom_g_summary_rows = data._summary_rows_grand.get_summary_rows(
+ GRAND_SUMMARY_GROUP_ID, "bottom"
)
for i, summary_row in enumerate(bottom_g_summary_rows):
row_html = _create_row_component_h(
@@ -725,8 +732,9 @@ def create_source_notes_component_h(data: GTData) -> str:
# Get the effective number of columns, which is number of columns
# that will finally be rendered accounting for the stub layout
+ has_summary_rows = bool(data._summary_rows or data._summary_rows_grand)
n_cols_total = data._boxhead._get_effective_number_of_columns(
- stub=data._stub, summary_rows=data._summary_rows, options=data._options
+ stub=data._stub, has_summary_rows=has_summary_rows, options=data._options
)
# Handle the multiline source notes case (each note takes up one line)
diff --git a/great_tables/_utils_render_latex.py b/great_tables/_utils_render_latex.py
index 882c81b8f..5b7806337 100644
--- a/great_tables/_utils_render_latex.py
+++ b/great_tables/_utils_render_latex.py
@@ -554,8 +554,9 @@ def _render_as_latex(data: GTData, use_longtable: bool = False, tbl_pos: str | N
_not_implemented("Styles are not yet supported in LaTeX output.")
# Get list representation of stub layout
+ has_summary_rows = bool(data._summary_rows or data._summary_rows_grand)
stub_layout = data._stub._get_stub_layout(
- summary_rows=data._summary_rows, options=data._options
+ has_summary_rows=has_summary_rows, options=data._options
)
# Throw exception if a stub is present in the table
From 16cb29e791cb2dbceb4bc7873ebb48c432b2d26f Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 22 Aug 2025 14:08:07 -0400
Subject: [PATCH 39/54] use eval_aggregate for summary rows
---
great_tables/_gt_data.py | 4 +-
great_tables/_modify_rows.py | 94 +++++++++++++++++-------------------
great_tables/_tbl_data.py | 68 ++++++++++++++++++++++++++
3 files changed, 114 insertions(+), 52 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 1392e8e01..b6d1d462d 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -991,7 +991,9 @@ class SummaryRowInfo:
id: str
label: str # For now, label and id are identical
- values: dict[str, str | int | float] # TODO: consider datatype, series?
+ # The motivation for values as a dict is to ensure cols_* functions don't have to consider
+ # the implications on SummaryRowInfo objects
+ values: dict[str, Any] # TODO: consider datatype, series?
side: Literal["top", "bottom"] # TODO: consider enum
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index e6ca5c645..e6c484907 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from statistics import quantiles
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Any, Callable, Literal
from great_tables._locations import resolve_cols_c
@@ -16,8 +16,10 @@
SummaryRowInfo,
)
from ._tbl_data import (
+ PlExpr,
SelectExpr,
- to_list,
+ TblData,
+ eval_aggregate,
)
if TYPE_CHECKING:
@@ -198,8 +200,8 @@ def with_id(self: GTSelf, id: str | None = None) -> GTSelf:
def grand_summary_rows(
self: GTSelf,
- fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
- # fns: dict[str, Callable[[list], Any]],
+ # fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
+ fns: dict[str, PlExpr] | dict[str, Callable[[TblData], Any]],
fmt: FormatFn | None = None,
columns: SelectExpr = None,
side: Literal["bottom", "top"] = "bottom",
@@ -243,18 +245,16 @@ def grand_summary_rows(
```
"""
- # Computes summary rows immediately but stores them separately from main data.
- normalized_fns = _normalize_fns_to_tuples(fns)
- for label, fn_callable in normalized_fns:
- row_values_dict = _calculate_summary_row(
- self, fn_callable, fmt, columns, missing_text, group_id=None
- )
+ summary_col_names = resolve_cols_c(data=self, expr=columns)
+
+ for label, fn in fns.items():
+ row_values_dict = _calculate_summary_row(self, fn, fmt, summary_col_names, missing_text)
summary_row_info = SummaryRowInfo(
id=label,
label=label,
- values=row_values_dict, # TODO: revisit type
+ values=row_values_dict,
side=side,
)
@@ -263,7 +263,38 @@ def grand_summary_rows(
return self
-# TODO: delegate to group by agg instead
+def _calculate_summary_row(
+ data: GTData,
+ fn: PlExpr | Callable[[TblData], Any],
+ fmt: FormatFn | None,
+ summary_col_names: list[str],
+ missing_text: str,
+) -> dict[str, Any]:
+ """Calculate a summary row using eval_transform."""
+ original_columns = data._boxhead._get_columns()
+ summary_row = {}
+
+ # Use eval_transform to apply the function/expression to the data
+ result_df = eval_aggregate(data._tbl_data, fn)
+
+ # Extract results for each column
+ for col in original_columns:
+ if col in summary_col_names and col in result_df:
+ print("rr ", result_df)
+ res = result_df[col]
+
+ if fmt is not None:
+ formatted = fmt([res])
+ res = formatted[0]
+
+ summary_row[col] = res
+ else:
+ summary_row[col] = missing_text
+
+ return summary_row
+
+
+# TODO: delegate to group by agg instead (group_by for summary row case)
# TODO: validates after
@@ -316,42 +347,3 @@ def _median(values: list[Any]) -> Any:
raise ValueError(f"Unknown function name: {fn_name}")
return builtin_functions[fn_name]
-
-
-def _calculate_summary_row(
- data: GTData,
- fn: SummaryFn,
- fmt: FormatFn | None,
- columns: SelectExpr,
- missing_text: str,
- group_id: str | None = None, # None means grand summary (all data)
-) -> dict[str, Any]:
- """Calculate a summary row based on the function and selected columns for a specific group."""
- original_columns = data._boxhead._get_columns()
-
- summary_col_names = resolve_cols_c(data=data, expr=columns)
-
- if group_id is None:
- group_id = GRAND_SUMMARY_GROUP_ID
- else:
- # Future: group-specific logic would go here
- raise NotImplementedError("Group-specific summaries not yet implemented")
-
- # Create summary row data as dict
- summary_row = {}
-
- for col in original_columns:
- if col in summary_col_names:
- col_data = to_list(data._tbl_data[col])
- res = fn(col_data)
-
- if fmt is not None:
- # The vals functions expect a list and return a list
- formatted_list = fmt([res])
- res = formatted_list[0]
-
- summary_row[col] = res
- else:
- summary_row[col] = missing_text
-
- return summary_row
diff --git a/great_tables/_tbl_data.py b/great_tables/_tbl_data.py
index 43798e364..aa0553920 100644
--- a/great_tables/_tbl_data.py
+++ b/great_tables/_tbl_data.py
@@ -874,3 +874,71 @@ def _(ser: PyArrowChunkedArray, name: Optional[str] = None) -> PyArrowTable:
import pyarrow as pa
return pa.table({name: ser})
+
+
+# eval_aggregate ----
+
+
+@singledispatch
+def eval_aggregate(df, expr) -> dict[str, Any]:
+ """Evaluate an expression against data and return a single row as a dictionary.
+
+ This is designed for aggregation operations that produce summary statistics.
+ The result should be a single row with values for each column.
+
+ Parameters
+ ----------
+ data
+ The input data (DataFrame)
+ expr
+ The expression to evaluate (Polars expression or callable)
+
+ Returns
+ -------
+ dict[str, Any]
+ A dictionary mapping column names to their aggregated values
+ """
+ raise NotImplementedError(f"eval_to_row not implemented for type: {type(df)}")
+
+
+@eval_aggregate.register
+def _(df: PdDataFrame, expr: Callable[[PdDataFrame], PdSeries]) -> dict[str, Any]:
+ """Evaluate a callable function and return results as a dict."""
+ res = expr(df)
+
+ if not isinstance(res, PdSeries):
+ raise ValueError(f"Result must be a pandas Series. Received {type(res)}")
+
+ return res.to_dict()
+
+
+@eval_aggregate.register
+def _(df: PlDataFrame, expr: PlExpr) -> dict[str, Any]:
+ """Evaluate a polars expression and return aggregated results as a dict."""
+ # Apply the expression to get aggregated results
+ res = df.select(expr)
+
+ # Convert the single-row result to a dictionary
+ if len(res) != 1:
+ raise ValueError(
+ f"Expression must produce exactly 1 row (aggregation). Got {len(res)} rows."
+ )
+
+ return res.to_dicts()[0]
+
+
+@eval_aggregate.register
+def _(df: PyArrowTable, expr: Callable[[PyArrowTable], PyArrowTable]) -> dict[str, Any]:
+ res = expr(df)
+
+ if not isinstance(res, PyArrowTable):
+ raise ValueError(f"Result must be a PyArrow Table. Received {type(res)}")
+
+ # Convert the single-row result to a dictionary
+ if res.num_rows != 1:
+ raise ValueError(
+ f"Expression must produce exactly 1 row (aggregation). Got {res.num_rows} rows."
+ )
+
+ # Convert to dictionary - PyArrow equivalent of .to_dicts()[0]
+ return {col: res.column(col)[0].as_py() for col in res.column_names}
From e53214849fa4787a1e75bc4b4c39175b153fdc39 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 22 Aug 2025 14:10:48 -0400
Subject: [PATCH 40/54] remove dead code
---
great_tables/_gt_data.py | 2 --
great_tables/_modify_rows.py | 55 ------------------------------------
2 files changed, 57 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index b6d1d462d..a047e0042 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -982,8 +982,6 @@ def __init__(self, func: FormatFns, cols: list[str], rows: list[int]):
# seperate data structure for grand summary row infos
GRAND_SUMMARY_GROUP_ID = "__grand_summary_group__"
-SummaryFn = Callable[..., Any]
-
@dataclass(frozen=True)
class SummaryRowInfo:
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index e6c484907..44b5c9baa 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-from statistics import quantiles
from typing import TYPE_CHECKING, Any, Callable, Literal
from great_tables._locations import resolve_cols_c
@@ -12,7 +11,6 @@
Locale,
RowGroups,
Styles,
- SummaryFn,
SummaryRowInfo,
)
from ._tbl_data import (
@@ -200,7 +198,6 @@ def with_id(self: GTSelf, id: str | None = None) -> GTSelf:
def grand_summary_rows(
self: GTSelf,
- # fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
fns: dict[str, PlExpr] | dict[str, Callable[[TblData], Any]],
fmt: FormatFn | None = None,
columns: SelectExpr = None,
@@ -280,7 +277,6 @@ def _calculate_summary_row(
# Extract results for each column
for col in original_columns:
if col in summary_col_names and col in result_df:
- print("rr ", result_df)
res = result_df[col]
if fmt is not None:
@@ -296,54 +292,3 @@ def _calculate_summary_row(
# TODO: delegate to group by agg instead (group_by for summary row case)
# TODO: validates after
-
-
-def _normalize_fns_to_tuples(
- fns: str | list[str] | list[SummaryFn] | dict[str, str] | dict[str, SummaryFn],
-) -> list[tuple[str, SummaryFn]]:
- """Convert all fns formats to a list of (label, callable) tuples."""
-
- # Case 1: Single string -> convert to list
- if isinstance(fns, str):
- fns = [fns]
-
- # Case 2: List of strings
- if isinstance(fns, list) and all(isinstance(fn, str) for fn in fns):
- return [(fn_name, _get_builtin_function(fn_name)) for fn_name in fns]
-
- # Case 3: List of callables -> infer labels from function names
- if isinstance(fns, list) and all(callable(fn) for fn in fns):
- return [(fn.__name__, fn) for fn in fns]
-
- # Case 4: Dict with string values -> convert strings to callables
- if isinstance(fns, dict) and all(isinstance(v, str) for v in fns.values()):
- return [(label, _get_builtin_function(fn_name)) for label, fn_name in fns.items()]
-
- # Case 5: Dict with callable values -> everything is given
- if isinstance(fns, dict) and all(callable(v) for v in fns.values()):
- return list(fns.items())
-
- raise ValueError(f"Unsupported fns format: {type(fns)} or mixed types in collection")
-
-
-def _get_builtin_function(fn_name: str) -> SummaryFn:
- """Convert string function name to actual callable function."""
-
- def _mean(values: list[Any]) -> float:
- return sum(values) / len(values)
-
- def _median(values: list[Any]) -> Any:
- return quantiles(values, n=2)[0]
-
- builtin_functions: dict[str, SummaryFn] = {
- "min": min,
- "max": max,
- "sum": sum,
- "mean": _mean,
- "median": _median,
- }
-
- if fn_name not in builtin_functions:
- raise ValueError(f"Unknown function name: {fn_name}")
-
- return builtin_functions[fn_name]
From bcd89d42989bda35044d5fc0dc3b1f9b62a264ce Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 22 Aug 2025 14:48:47 -0400
Subject: [PATCH 41/54] docstring
---
great_tables/_modify_rows.py | 52 ++++++++++++++++++++++++++++++++----
1 file changed, 47 insertions(+), 5 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 44b5c9baa..42a8dd9c6 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -210,15 +210,18 @@ def grand_summary_rows(
grand summary rows, all of the available data in the gt table is incorporated (regardless of
whether some of the data are part of row groups). Multiple grand summary rows can be added via
expressions given to fns. You can selectively format the values in the resulting grand summary
- cells by use of formatting expressions in fmt.
+ cells by use of formatting expressions from the `vals.fmt_*` class of functions.
Parameters
----------
-
fns
- TODO text
+ A dictionary mapping row labels to aggregation expressions. Can be either Polars
+ expressions or callable functions that take the entire DataFrame and return aggregated
+ results. Each key becomes the label for a grand summary row.
fmt
- TODO text
+ A formatting function from the `vals.fmt_*` family (e.g., `vals.fmt_number`,
+ `vals.fmt_currency`) to apply to the summary row values. If `None`, no formatting
+ is applied.
columns
The columns to target. Can either be a single column name or a series of column names
provided in a list.
@@ -236,9 +239,48 @@ def grand_summary_rows(
Examples
--------
- TODO Explanation
+ Let's use a subset of the `sp500` dataset to create a table with grand summary rows. We'll
+ calculate min, max, and mean values for the numeric columns.
+
```{python}
+ import polars as pl
+ from great_tables import GT, vals, style, loc
+ from great_tables.data import sp500
+
+ sp500_mini = (
+ pl.from_pandas(sp500)
+ .slice(0, 7)
+ .drop(["volume", "adj_close"])
+ )
+
+ (
+ GT(sp500_mini, rowname_col="date")
+ .grand_summary_rows(
+ fns={
+ "Minimum": pl.min("*"),
+ "Maximum": pl.max("*"),
+ "Average": pl.mean("*"),
+ },
+ fmt=vals.fmt_currency,
+ columns=["open", "high", "low", "close"],
+ )
+ .tab_style(
+ style=[
+ style.text(color="crimson"),
+ style.fill(color="lightgray"),
+ ],
+ locations=loc.grand_summary(),
+ )
+ )
+ ```
+
+ We can also use custom callable functions to create more complex summary calculations:
+
+ TODO pandas ex
+
+ Grand summary rows can be placed at the top of the table and formatted with currency notation:
+ TODO example
```
"""
From 0b4159bb02933ebf5a66d397d411043f0b4d5164 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Fri, 22 Aug 2025 15:05:41 -0400
Subject: [PATCH 42/54] testing docstring in site preview
---
great_tables/_locations.py | 40 ++++++++++++++++++--------------------
1 file changed, 19 insertions(+), 21 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 6aefab237..d1bcd9c91 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -525,9 +525,7 @@ class LocGrandSummaryStub(Loc):
rowname_col="model",
groupname_col="mfr",
)
- .tab_options(row_group_as_column=True)
- .grand_summary_rows(fns=["min", "max"], side="top")
- .grand_summary_rows(fns="mean", side="bottom")
+ .grand_summary_rows(fns={"min": lambda x: x.min(), "max": lambda x: x.min()}, side="top")
.tab_style(
style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
locations=loc.grand_summary_stub(),
@@ -645,24 +643,24 @@ class LocGrandSummary(Loc):
[`tab_style()`](`great_tables.GT.tab_style`).
```{python}
- from great_tables import GT, style, loc
- from great_tables.data import gtcars
-
- (
- GT(
- gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(5),
- rowname_col="model",
- groupname_col="mfr",
- )
- .tab_options(row_group_as_column=True)
- .grand_summary_rows(fns=["min", "max"], side="top")
- .grand_summary_rows(fns="mean", side="bottom")
- .tab_style(
- style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
- locations=loc.grand_summary(),
- )
- .fmt_integer(columns=["hp", "trq", "mpg_c"])
- )
+ # from great_tables import GT, style, loc
+ # from great_tables.data import gtcars
+
+ # (
+ # GT(
+ # gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(5),
+ # rowname_col="model",
+ # groupname_col="mfr",
+ # )
+ # .tab_options(row_group_as_column=True)
+ # .grand_summary_rows(fns=["min", "max"], side="top")
+ # .grand_summary_rows(fns="mean", side="bottom")
+ # .tab_style(
+ # style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
+ # locations=loc.grand_summary(),
+ # )
+ # .fmt_integer(columns=["hp", "trq", "mpg_c"])
+ # )
```
"""
From 3aab95d67527b89a6efa9474c6c61610cfd7f9be Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Mon, 25 Aug 2025 09:37:49 -0400
Subject: [PATCH 43/54] fix build
---
docs/get-started/targeted-styles.qmd | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/get-started/targeted-styles.qmd b/docs/get-started/targeted-styles.qmd
index a741383df..ef7eed353 100644
--- a/docs/get-started/targeted-styles.qmd
+++ b/docs/get-started/targeted-styles.qmd
@@ -44,7 +44,7 @@ gt = (
.tab_source_note("yo")
.tab_spanner("spanner", ["char", "fctr"])
.tab_stubhead("stubhead")
- .grand_summary_rows(fns="sum", columns="num")
+ # .grand_summary_rows(fns={"sum": lambda x: x.sum("num")}, columns="num")
)
(
From 4eecef0eabea3ce05c004f7ddad8d695f87d07a9 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Mon, 25 Aug 2025 11:43:15 -0400
Subject: [PATCH 44/54] refactor to rely on class attribute to determine if
grand summary
---
great_tables/_gt_data.py | 36 +++++++++++++++++++++++-------
great_tables/_locations.py | 5 ++---
great_tables/_modify_rows.py | 5 ++---
great_tables/_utils_render_html.py | 7 ++----
4 files changed, 34 insertions(+), 19 deletions(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index a047e0042..6c0e46a8f 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -125,7 +125,7 @@ def from_data(
_heading=Heading(),
_stubhead=None,
_summary_rows=SummaryRows(),
- _summary_rows_grand=SummaryRows(),
+ _summary_rows_grand=SummaryRows(_is_grand_summary=True),
_source_notes=[],
_footnotes=[],
_styles=[],
@@ -980,7 +980,6 @@ def __init__(self, func: FormatFns, cols: list[str], rows: list[int]):
# This can't conflict with actual group ids since we have a
# seperate data structure for grand summary row infos
-GRAND_SUMMARY_GROUP_ID = "__grand_summary_group__"
@dataclass(frozen=True)
@@ -990,7 +989,7 @@ class SummaryRowInfo:
id: str
label: str # For now, label and id are identical
# The motivation for values as a dict is to ensure cols_* functions don't have to consider
- # the implications on SummaryRowInfo objects
+ # the implications on existing SummaryRowInfo objects
values: dict[str, Any] # TODO: consider datatype, series?
side: Literal["top", "bottom"] # TODO: consider enum
@@ -1007,21 +1006,36 @@ class SummaryRows(Mapping[str, list[SummaryRowInfo]]):
"""
_d: dict[str, list[SummaryRowInfo]]
+ _is_grand_summary: bool
- def __init__(self):
+ GRAND_SUMMARY_KEY = "grand"
+
+ def __init__(self, _is_grand_summary: bool = False):
self._d = {}
+ self._is_grand_summary = _is_grand_summary
def __bool__(self) -> bool:
"""Return True if there are any summary rows, False otherwise."""
return len(self._d) > 0
- def __getitem__(self, key: str) -> list[SummaryRowInfo]:
- """Get a summary row by its ID."""
+ def __getitem__(self, key: str | None) -> list[SummaryRowInfo]:
+ if self._is_grand_summary:
+ key = SummaryRows.GRAND_SUMMARY_KEY
+
+ if not key:
+ raise KeyError("Summary row group key must not be None for group summary rows.")
+
+ if key not in self._d:
+ raise KeyError(f"Group '{key}' not found in summary rows.")
+
return self._d[key]
- def add_summary_row(self, summary_row: SummaryRowInfo, group_id: str) -> None:
+ def add_summary_row(self, summary_row: SummaryRowInfo, group_id: str | None = None) -> None:
"""Add a summary row following the merging rules in the class docstring."""
+ if self._is_grand_summary:
+ group_id = SummaryRows.GRAND_SUMMARY_KEY
+
existing_group = self.get(group_id)
if not existing_group:
@@ -1068,11 +1082,17 @@ def add_summary_row(self, summary_row: SummaryRowInfo, group_id: str) -> None:
return
- def get_summary_rows(self, group_id: str, side: str | None = None) -> list[SummaryRowInfo]:
+ def get_summary_rows(
+ self, group_id: str | None = None, side: str | None = None
+ ) -> list[SummaryRowInfo]:
"""Get list of summary rows for that group. If side is None, do not filter by side.
Sorts result with 'top' side first, then 'bottom'."""
result: list[SummaryRowInfo] = []
+
+ if self._is_grand_summary:
+ group_id = SummaryRows.GRAND_SUMMARY_KEY
+
summary_row_group = self.get(group_id)
if summary_row_group:
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index d1bcd9c91..008f6c1f4 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -11,7 +11,6 @@
# resolve generic, but we need to import at runtime, due to singledispatch looking
# up annotations
from ._gt_data import (
- GRAND_SUMMARY_GROUP_ID,
ColInfoTypeEnum,
FootnoteInfo,
FootnotePlacement,
@@ -1026,7 +1025,7 @@ def _(loc: LocRowGroups, data: GTData) -> set[str]:
@resolve.register
def _(loc: LocGrandSummaryStub, data: GTData) -> set[int]:
# Select just grand summary rows
- grand_summary_rows = data._summary_rows_grand.get_summary_rows(GRAND_SUMMARY_GROUP_ID)
+ grand_summary_rows = data._summary_rows_grand.get_summary_rows()
grand_summary_rows_ids = [row.id for row in grand_summary_rows]
rows = resolve_rows_i(data=grand_summary_rows_ids, expr=loc.rows)
@@ -1054,7 +1053,7 @@ def _(loc: LocGrandSummary, data: GTData) -> list[CellPos]:
"Cannot specify the `mask` argument along with `columns` or `rows` in `loc.body()`."
)
- grand_summary_rows = data._summary_rows_grand.get_summary_rows(GRAND_SUMMARY_GROUP_ID)
+ grand_summary_rows = data._summary_rows_grand.get_summary_rows()
grand_summary_rows_ids = [row.id for row in grand_summary_rows]
if loc.mask is None:
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 42a8dd9c6..913e74084 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -5,7 +5,6 @@
from great_tables._locations import resolve_cols_c
from ._gt_data import (
- GRAND_SUMMARY_GROUP_ID,
FormatFn,
GTData,
Locale,
@@ -297,7 +296,7 @@ def grand_summary_rows(
side=side,
)
- self._summary_rows_grand.add_summary_row(summary_row_info, GRAND_SUMMARY_GROUP_ID)
+ self._summary_rows_grand.add_summary_row(summary_row_info)
return self
@@ -313,7 +312,7 @@ def _calculate_summary_row(
original_columns = data._boxhead._get_columns()
summary_row = {}
- # Use eval_transform to apply the function/expression to the data
+ # Use eval_aggregate to apply the function/expression to the data
result_df = eval_aggregate(data._tbl_data, fn)
# Extract results for each column
diff --git a/great_tables/_utils_render_html.py b/great_tables/_utils_render_html.py
index 8b1c0a9f6..f3a893023 100644
--- a/great_tables/_utils_render_html.py
+++ b/great_tables/_utils_render_html.py
@@ -7,7 +7,6 @@
from . import _locations as loc
from ._gt_data import (
- GRAND_SUMMARY_GROUP_ID,
ColInfo,
ColInfoTypeEnum,
GroupRowInfo,
@@ -489,7 +488,7 @@ def create_body_component_h(data: GTData) -> str:
body_rows: list[str] = []
# Add grand summary rows at top
- top_g_summary_rows = data._summary_rows_grand.get_summary_rows(GRAND_SUMMARY_GROUP_ID, "top")
+ top_g_summary_rows = data._summary_rows_grand.get_summary_rows(side="top")
for i, summary_row in enumerate(top_g_summary_rows):
row_html = _create_row_component_h(
column_vars=column_vars,
@@ -578,9 +577,7 @@ def create_body_component_h(data: GTData) -> str:
## if this table has summary rows
# Add grand summary rows at bottom
- bottom_g_summary_rows = data._summary_rows_grand.get_summary_rows(
- GRAND_SUMMARY_GROUP_ID, "bottom"
- )
+ bottom_g_summary_rows = data._summary_rows_grand.get_summary_rows(side="bottom")
for i, summary_row in enumerate(bottom_g_summary_rows):
row_html = _create_row_component_h(
column_vars=column_vars,
From b472da4ec2144b610853f80cb196bd15fb32098c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 11:32:51 -0400
Subject: [PATCH 45/54] test eval_aggregate
---
great_tables/_tbl_data.py | 8 +--
tests/test_tbl_data.py | 109 ++++++++++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 7 deletions(-)
diff --git a/great_tables/_tbl_data.py b/great_tables/_tbl_data.py
index aa0553920..0d3418209 100644
--- a/great_tables/_tbl_data.py
+++ b/great_tables/_tbl_data.py
@@ -898,12 +898,11 @@ def eval_aggregate(df, expr) -> dict[str, Any]:
dict[str, Any]
A dictionary mapping column names to their aggregated values
"""
- raise NotImplementedError(f"eval_to_row not implemented for type: {type(df)}")
+ raise NotImplementedError(f"eval_aggregate not implemented for type: {type(df)}")
@eval_aggregate.register
def _(df: PdDataFrame, expr: Callable[[PdDataFrame], PdSeries]) -> dict[str, Any]:
- """Evaluate a callable function and return results as a dict."""
res = expr(df)
if not isinstance(res, PdSeries):
@@ -914,11 +913,8 @@ def _(df: PdDataFrame, expr: Callable[[PdDataFrame], PdSeries]) -> dict[str, Any
@eval_aggregate.register
def _(df: PlDataFrame, expr: PlExpr) -> dict[str, Any]:
- """Evaluate a polars expression and return aggregated results as a dict."""
- # Apply the expression to get aggregated results
res = df.select(expr)
- # Convert the single-row result to a dictionary
if len(res) != 1:
raise ValueError(
f"Expression must produce exactly 1 row (aggregation). Got {len(res)} rows."
@@ -934,11 +930,9 @@ def _(df: PyArrowTable, expr: Callable[[PyArrowTable], PyArrowTable]) -> dict[st
if not isinstance(res, PyArrowTable):
raise ValueError(f"Result must be a PyArrow Table. Received {type(res)}")
- # Convert the single-row result to a dictionary
if res.num_rows != 1:
raise ValueError(
f"Expression must produce exactly 1 row (aggregation). Got {res.num_rows} rows."
)
- # Convert to dictionary - PyArrow equivalent of .to_dicts()[0]
return {col: res.column(col)[0].as_py() for col in res.column_names}
diff --git a/tests/test_tbl_data.py b/tests/test_tbl_data.py
index 335c06fb3..d58307240 100644
--- a/tests/test_tbl_data.py
+++ b/tests/test_tbl_data.py
@@ -15,6 +15,7 @@
_validate_selector_list,
cast_frame_to_string,
create_empty_frame,
+ eval_aggregate,
eval_select,
get_column_names,
group_splits,
@@ -323,3 +324,111 @@ def test_copy_frame(df: DataFrameLike):
copy_df = copy_frame(df)
assert id(copy_df) != id(df)
assert_frame_equal(copy_df, df)
+
+
+def test_eval_aggregate_pandas(df: DataFrameLike):
+ def expr(df):
+ return pd.Series({"col1_sum": sum(df["col1"]), "col3_max": max(df["col3"])})
+
+ # Only pandas supports callable aggregation expressions
+ if isinstance(df, pl.DataFrame):
+ with pytest.raises(TypeError) as exc_info:
+ eval_aggregate(df, expr)
+ assert "cannot create expression literal for value of type function" in str(
+ exc_info.value.args[0]
+ )
+ return
+
+ if isinstance(df, pa.Table):
+ with pytest.raises(TypeError) as exc_info:
+ eval_aggregate(df, expr)
+ assert "unsupported operand type(s)" in str(exc_info.value.args[0])
+ return
+
+ result = eval_aggregate(df, expr)
+ assert result == {"col1_sum": 6, "col3_max": 6.0}
+
+
+@pytest.mark.parametrize(
+ "expr,expected",
+ [
+ (pl.col("col1").sum(), {"col1": 6}),
+ (pl.col("col2").first(), {"col2": "a"}),
+ (pl.col("col3").max(), {"col3": 6.0}),
+ ],
+)
+def test_eval_aggregate_polars(df: DataFrameLike, expr, expected):
+ # Only polars supports polars expression aggregations
+ if not isinstance(df, pl.DataFrame):
+ with pytest.raises(TypeError) as exc_info:
+ eval_aggregate(df, expr)
+ assert "'Expr' object is not callable" in str(exc_info.value.args[0])
+ return
+
+ result = eval_aggregate(df, expr)
+ assert result == expected
+
+
+@pytest.mark.parametrize("Frame", [pd.DataFrame, pl.DataFrame, pa.table])
+def test_eval_aggregate_with_nulls(Frame):
+ df = Frame({"a": [1, None, 3]})
+
+ if isinstance(df, pd.DataFrame):
+
+ def expr(df):
+ return pd.Series({"a": df["a"].sum()})
+
+ if isinstance(df, pl.DataFrame):
+ expr = pl.col("a").sum()
+
+ if isinstance(df, pa.Table):
+
+ def expr(tbl):
+ s = pa.compute.sum(tbl.column("a"))
+ return pa.table({"a": [s.as_py()]})
+
+ result = eval_aggregate(df, expr)
+ assert result == {"a": 4}
+
+
+def test_eval_aggregate_pandas_raises():
+ df = pd.DataFrame({"a": [1, 2, 3]})
+
+ def expr(df):
+ return {"a": df["a"].sum()}
+
+ with pytest.raises(ValueError) as exc_info:
+ eval_aggregate(df, expr)
+ assert "Result must be a pandas Series" in str(exc_info.value)
+
+
+def test_eval_aggregate_polars_raises():
+ df = pl.DataFrame({"a": [1, 2, 3]})
+ expr = pl.col("a")
+
+ with pytest.raises(ValueError) as exc_info:
+ eval_aggregate(df, expr)
+ assert "Expression must produce exactly 1 row" in str(exc_info.value)
+
+
+def test_eval_aggregate_pyarrow_raises1():
+ df = pa.table({"a": [1, 2, 3]})
+
+ def expr(tbl):
+ s = pa.compute.sum(tbl.column("a"))
+ return {"a": [s.as_py()]}
+
+ with pytest.raises(ValueError) as exc_info:
+ eval_aggregate(df, expr)
+ assert "Result must be a PyArrow Table" in str(exc_info.value)
+
+
+def test_eval_aggregate_pyarrow_raises2():
+ df = pa.table({"a": [1, 2, 3]})
+
+ def expr(tbl):
+ return pa.table({"a": tbl.column("a")})
+
+ with pytest.raises(ValueError) as exc_info:
+ eval_aggregate(df, expr)
+ assert "Expression must produce exactly 1 row (aggregation)" in str(exc_info.value)
From 4b8ed6376b32365aa0cb465784d5b509a2df0893 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 12:38:58 -0400
Subject: [PATCH 46/54] grand summary rows tests added
---
great_tables/_modify_rows.py | 17 +--
tests/__snapshots__/test_modify_rows.ambr | 52 ++++++++
tests/test_modify_rows.py | 147 +++++++++++++++++++++-
3 files changed, 207 insertions(+), 9 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 913e74084..63fa7888a 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -2,8 +2,6 @@
from typing import TYPE_CHECKING, Any, Callable, Literal
-from great_tables._locations import resolve_cols_c
-
from ._gt_data import (
FormatFn,
GTData,
@@ -222,8 +220,7 @@ def grand_summary_rows(
`vals.fmt_currency`) to apply to the summary row values. If `None`, no formatting
is applied.
columns
- The columns to target. Can either be a single column name or a series of column names
- provided in a list.
+ Currently, this function does not support selection by columns.
side
Should the grand summary rows be placed at the `"bottom"` (the default) or the `"top"` of
the table?
@@ -283,11 +280,15 @@ def grand_summary_rows(
```
"""
+ if columns is not None:
+ raise NotImplementedError(
+ "Currently, grand_summary_rows() does not support column selection."
+ )
- summary_col_names = resolve_cols_c(data=self, expr=columns)
+ # summary_col_names = resolve_cols_c(data=self, expr=columns)
for label, fn in fns.items():
- row_values_dict = _calculate_summary_row(self, fn, fmt, summary_col_names, missing_text)
+ row_values_dict = _calculate_summary_row(self, fn, fmt, missing_text)
summary_row_info = SummaryRowInfo(
id=label,
@@ -305,7 +306,7 @@ def _calculate_summary_row(
data: GTData,
fn: PlExpr | Callable[[TblData], Any],
fmt: FormatFn | None,
- summary_col_names: list[str],
+ # summary_col_names: list[str],
missing_text: str,
) -> dict[str, Any]:
"""Calculate a summary row using eval_transform."""
@@ -317,7 +318,7 @@ def _calculate_summary_row(
# Extract results for each column
for col in original_columns:
- if col in summary_col_names and col in result_df:
+ if col in result_df:
res = result_df[col]
if fmt is not None:
diff --git a/tests/__snapshots__/test_modify_rows.ambr b/tests/__snapshots__/test_modify_rows.ambr
index 2480ef86a..6d4932170 100644
--- a/tests/__snapshots__/test_modify_rows.ambr
+++ b/tests/__snapshots__/test_modify_rows.ambr
@@ -1,4 +1,56 @@
# serializer version: 1
+# name: test_grand_summary_rows[pd_and_pl]
+ '''
+
+
+ |
+ 1 |
+ 4 |
+
+
+ |
+ 2 |
+ 5 |
+
+
+ |
+ 3 |
+ 6 |
+
+
+ Average |
+ 2.0 |
+ 5.0 |
+
+
+ Maximum |
+ 3 |
+ 6 |
+
+
+ '''
+# ---
+# name: test_grand_summary_rows_with_rowname
+ '''
+
+
+ x |
+ 1 |
+ 4 |
+
+
+ y |
+ 2 |
+ 5 |
+
+
+ Average |
+ 1.5 |
+ 4.5 |
+
+
+ '''
+# ---
# name: test_row_group_order
'''
diff --git a/tests/test_modify_rows.py b/tests/test_modify_rows.py
index a20e8b771..e5ba01258 100644
--- a/tests/test_modify_rows.py
+++ b/tests/test_modify_rows.py
@@ -1,6 +1,8 @@
import pandas as pd
+import polars as pl
+import pytest
-from great_tables import GT, loc, style
+from great_tables import GT, loc, style, vals
from great_tables._utils_render_html import create_body_component_h
@@ -11,6 +13,18 @@ def assert_rendered_body(snapshot, gt):
assert snapshot == body
+def mean_expr(df: pd.DataFrame):
+ return df.mean(numeric_only=True)
+
+
+def min_expr(df: pd.DataFrame):
+ return df.min(numeric_only=True)
+
+
+def max_expr(df: pd.DataFrame):
+ return df.max(numeric_only=True)
+
+
def test_row_group_order(snapshot):
gt = GT(pd.DataFrame({"g": ["a", "b"], "x": [1, 2], "y": [3, 4]}), groupname_col="g")
@@ -167,3 +181,134 @@ def test_with_id_preserves_other_options():
new_gt = gt.with_id("zzz")
assert new_gt._options.table_id.value == "zzz"
assert new_gt._options.container_width.value == "20px"
+
+
+def test_grand_summary_rows(snapshot):
+ for Frame in [pd.DataFrame, pl.DataFrame]:
+ df = Frame({"a": [1, 2, 3], "b": [4, 5, 6]})
+
+ if isinstance(df, pd.DataFrame):
+
+ def mean_expr(df):
+ return df.mean()
+
+ def max_expr(df):
+ return df.max()
+
+ if isinstance(df, pl.DataFrame):
+ mean_expr = pl.all().mean()
+ max_expr = pl.all().max()
+
+ res = GT(df).grand_summary_rows(fns={"Average": mean_expr, "Maximum": max_expr})
+
+ assert_rendered_body(snapshot(name="pd_and_pl"), res)
+
+
+def test_grand_summary_rows_with_rowname(snapshot):
+ df = pd.DataFrame({"a": [1, 2], "b": [4, 5], "row": ["x", "y"]})
+
+ res = GT(df, rowname_col="row").grand_summary_rows(fns={"Average": mean_expr})
+
+ assert_rendered_body(snapshot, res)
+
+
+def test_grand_summary_rows_with_groupname():
+ df = pd.DataFrame({"a": [1, 2], "b": [4, 5], "group": ["x", "y"]})
+
+ res = GT(df, groupname_col="group").grand_summary_rows(fns={"Average": mean_expr})
+ html = res.as_raw_html()
+
+ assert 'x | ' in html
+ assert ' | ' in html
+ assert (
+ 'Average | '
+ in html
+ )
+
+
+def test_grand_summary_rows_with_rowname_and_groupname():
+ df = pd.DataFrame({"a": [1, 2], "group": ["x", "x"], "row": ["row1", "row2"]})
+
+ res = (
+ GT(df, rowname_col="row", groupname_col="group")
+ .grand_summary_rows(fns={"Average": mean_expr})
+ .tab_options(row_group_as_column=True)
+ )
+ html = res.as_raw_html()
+
+ assert 'rowspan="2">x' in html
+ assert (
+ 'Average | '
+ in html
+ )
+
+
+def test_grand_summary_rows_with_missing():
+ df = pd.DataFrame({"a": [1, 2], "non_numeric": ["x", "y"]})
+
+ res = GT(df).grand_summary_rows(
+ fns={"Average": mean_expr},
+ missing_text="missing_text",
+ )
+ html = res.as_raw_html()
+
+ assert "missing_text" in html
+
+
+def test_grand_summary_rows_bottom_and_top():
+ df = pd.DataFrame({"a": [1, 2]})
+
+ res = (
+ GT(df)
+ .grand_summary_rows(fns={"Top": min_expr}, side="top")
+ .grand_summary_rows(fns={"Bottom": max_expr}, side="bottom")
+ )
+ html = res.as_raw_html()
+
+ assert (
+ 'gt_first_grand_summary_row_bottom gt_row gt_left gt_stub gt_grand_summary_row">Bottom'
+ in html
+ )
+ assert (
+ 'gt_last_grand_summary_row_top gt_row gt_left gt_stub gt_grand_summary_row">Top'
+ in html
+ )
+
+
+def test_grand_summary_rows_overwritten_row_maintains_location():
+ df = pd.DataFrame({"a": [1, 2], "row": ["x", "y"]})
+
+ res = (
+ GT(df)
+ .grand_summary_rows(fns={"Overwritten": min_expr}, side="top")
+ .grand_summary_rows(fns={"Overwritten": max_expr}, side="bottom")
+ )
+ html = res.as_raw_html()
+
+ assert '"gt_last_grand_summary_row_top' in html
+ assert '"gt_first_grand_summary_row_bottom' not in html
+
+ assert 'gt_grand_summary_row">1' not in html
+ assert 'gt_grand_summary_row">2' in html
+
+
+def test_grand_summary_rows_with_fmt():
+ df = pd.DataFrame({"a": [1, 3], "row": ["x", "y"]})
+
+ res = GT(df).grand_summary_rows(fns={"Average": mean_expr}, fmt=vals.fmt_integer)
+ html = res.as_raw_html()
+
+ assert 'gt_grand_summary_row">2' in html
+ assert 'gt_grand_summary_row">2.0' not in html
+
+
+def test_grand_summary_rows_raises_columns_not_implemented():
+ df = pd.DataFrame({"a": [1, 2], "row": ["x", "y"]})
+
+ with pytest.raises(NotImplementedError) as exc_info:
+ GT(df).grand_summary_rows(fns={"Minimum": min_expr}, columns="b")
+
+ assert (
+ "Currently, grand_summary_rows() does not support column selection."
+ in exc_info.value.args[0]
+ )
From e6d0fffb049b043e9242153e5f3032009fdcd79e Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 12:39:58 -0400
Subject: [PATCH 47/54] fix kitchen sink example
---
docs/get-started/targeted-styles.qmd | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/get-started/targeted-styles.qmd b/docs/get-started/targeted-styles.qmd
index ef7eed353..84ac63c37 100644
--- a/docs/get-started/targeted-styles.qmd
+++ b/docs/get-started/targeted-styles.qmd
@@ -44,7 +44,7 @@ gt = (
.tab_source_note("yo")
.tab_spanner("spanner", ["char", "fctr"])
.tab_stubhead("stubhead")
- # .grand_summary_rows(fns={"sum": lambda x: x.sum("num")}, columns="num")
+ .grand_summary_rows(fns={"sum": lambda x: x.sum(numeric_only=True)})
)
(
From 954ad1f6e97627d961e9bd86b14466c31ec70ec8 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 12:44:27 -0400
Subject: [PATCH 48/54] ensure example compiles
---
great_tables/_modify_rows.py | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index 63fa7888a..d2ef3c1a9 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -240,25 +240,21 @@ def grand_summary_rows(
```{python}
import polars as pl
+ import polars.selectors as cs
from great_tables import GT, vals, style, loc
from great_tables.data import sp500
- sp500_mini = (
- pl.from_pandas(sp500)
- .slice(0, 7)
- .drop(["volume", "adj_close"])
- )
+ sp500_mini = pl.from_pandas(sp500).slice(0, 7).drop(["volume", "adj_close"])
(
GT(sp500_mini, rowname_col="date")
.grand_summary_rows(
fns={
- "Minimum": pl.min("*"),
- "Maximum": pl.max("*"),
- "Average": pl.mean("*"),
+ "Minimum": cs.numeric().min(),
+ "Maximum": cs.numeric().max(),
+ "Average": pl.mean("open", "close"),
},
fmt=vals.fmt_currency,
- columns=["open", "high", "low", "close"],
)
.tab_style(
style=[
From e08069b56133b7f0c568ef685fd34b80c5c936c5 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 13:12:41 -0400
Subject: [PATCH 49/54] locations tests
---
tests/test_locations.py | 49 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
diff --git a/tests/test_locations.py b/tests/test_locations.py
index 3dbc75d5a..ff9cdaf14 100644
--- a/tests/test_locations.py
+++ b/tests/test_locations.py
@@ -13,6 +13,8 @@
LocSpannerLabels,
LocStub,
LocTitle,
+ LocGrandSummaryStub,
+ LocGrandSummary,
resolve,
resolve_cols_i,
resolve_rows_i,
@@ -295,3 +297,50 @@ def test_set_style_loc_title_from_column_error(snapshot):
set_style(loc, gt_df, [style])
assert snapshot == exc_info.value.args[0]
+
+
+@pytest.mark.parametrize(
+ "rows, res",
+ [
+ (0, {0}),
+ ("min", {1}),
+ (["min"], {1}),
+ (["min", 0], {0, 1}),
+ (["min", -1], {1}),
+ ],
+)
+def test_resolve_loc_grand_summary_stub(rows, res):
+ df = pd.DataFrame({"x": [1, 2], "y": [3, 4]})
+ gt = (
+ GT(df)
+ .grand_summary_rows(fns={"min": lambda x: x.min()}, side="bottom")
+ .grand_summary_rows({"max": lambda x: x.max()}, side="top")
+ )
+
+ cells = resolve(LocGrandSummaryStub(rows), gt)
+
+ assert cells == res
+
+
+@pytest.mark.parametrize(
+ "cols, rows, resolved_subset, length",
+ [
+ (["x"], ["max"], CellPos(column=0, row=0, colname="x", rowname=None), 1),
+ ([1], ["min"], CellPos(column=1, row=1, colname="y", rowname=None), 1),
+ ([-1], [0, 1], CellPos(column=1, row=0, colname="y", rowname=None), 2),
+ ([-1, "x"], ["max", 1], CellPos(column=0, row=0, colname="x", rowname=None), 4),
+ ],
+)
+def test_resolve_loc_grand_summary(cols, rows, resolved_subset, length):
+ df = pd.DataFrame({"x": [1, 2], "y": [3, 4]})
+ gt = (
+ GT(df)
+ .grand_summary_rows(fns={"min": lambda x: x.min()}, side="bottom")
+ .grand_summary_rows({"max": lambda x: x.max()}, side="top")
+ )
+
+ cells = resolve(LocGrandSummary(columns=cols, rows=rows), gt)
+
+ assert isinstance(cells, list)
+ assert len(cells) == length
+ assert resolved_subset in cells
From f590355a41f073d83ae0afe9384321494ed1962c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 13:27:32 -0400
Subject: [PATCH 50/54] snapshot updates, cover case in utils_render_html
---
tests/__snapshots__/test_modify_rows.ambr | 27 +++++++++++++++++++++--
tests/test_modify_rows.py | 20 ++++++++---------
2 files changed, 34 insertions(+), 13 deletions(-)
diff --git a/tests/__snapshots__/test_modify_rows.ambr b/tests/__snapshots__/test_modify_rows.ambr
index 6d4932170..78df6a3ca 100644
--- a/tests/__snapshots__/test_modify_rows.ambr
+++ b/tests/__snapshots__/test_modify_rows.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_grand_summary_rows[pd_and_pl]
+# name: test_grand_summary_rows_snap[pd_and_pl]
'''
@@ -30,7 +30,30 @@
'''
# ---
-# name: test_grand_summary_rows_with_rowname
+# name: test_grand_summary_rows_with_group_as_col_snap
+ '''
+
+
+ x |
+ 1 |
+ 4 |
+
+
+ y |
+ 2 |
+ 5 |
+
+
+ Average |
+ 1.5 |
+ 4.5 |
+
+
+ '''
+# ---
+# name: test_grand_summary_rows_with_rowname_snap
'''
diff --git a/tests/test_modify_rows.py b/tests/test_modify_rows.py
index e5ba01258..3547715b2 100644
--- a/tests/test_modify_rows.py
+++ b/tests/test_modify_rows.py
@@ -183,7 +183,7 @@ def test_with_id_preserves_other_options():
assert new_gt._options.container_width.value == "20px"
-def test_grand_summary_rows(snapshot):
+def test_grand_summary_rows_snap(snapshot):
for Frame in [pd.DataFrame, pl.DataFrame]:
df = Frame({"a": [1, 2, 3], "b": [4, 5, 6]})
@@ -204,7 +204,7 @@ def max_expr(df):
assert_rendered_body(snapshot(name="pd_and_pl"), res)
-def test_grand_summary_rows_with_rowname(snapshot):
+def test_grand_summary_rows_with_rowname_snap(snapshot):
df = pd.DataFrame({"a": [1, 2], "b": [4, 5], "row": ["x", "y"]})
res = GT(df, rowname_col="row").grand_summary_rows(fns={"Average": mean_expr})
@@ -212,19 +212,17 @@ def test_grand_summary_rows_with_rowname(snapshot):
assert_rendered_body(snapshot, res)
-def test_grand_summary_rows_with_groupname():
+def test_grand_summary_rows_with_group_as_col_snap(snapshot):
df = pd.DataFrame({"a": [1, 2], "b": [4, 5], "group": ["x", "y"]})
- res = GT(df, groupname_col="group").grand_summary_rows(fns={"Average": mean_expr})
- html = res.as_raw_html()
-
- assert 'x | ' in html
- assert ' | ' in html
- assert (
- 'Average | '
- in html
+ res = (
+ GT(df, groupname_col="group")
+ .grand_summary_rows(fns={"Average": mean_expr})
+ .tab_options(row_group_as_column=True)
)
+ assert_rendered_body(snapshot, res)
+
def test_grand_summary_rows_with_rowname_and_groupname():
df = pd.DataFrame({"a": [1, 2], "group": ["x", "x"], "row": ["row1", "row2"]})
From f16d80a430bc2c9e3881649649ca5d1de36d02e7 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 15:13:08 -0400
Subject: [PATCH 51/54] main docstring
---
great_tables/_locations.py | 58 +++++++++++++++++++++---------------
great_tables/_modify_rows.py | 49 ++++++++++++++++++++++++------
2 files changed, 74 insertions(+), 33 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 008f6c1f4..2fa58bc7b 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -515,21 +515,27 @@ class LocGrandSummaryStub(Loc):
[`tab_style()`](`great_tables.GT.tab_style`).
```{python}
- from great_tables import GT, style, loc
+ from great_tables import GT, style, loc, vals
from great_tables.data import gtcars
(
GT(
- gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(5),
+ gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(6),
rowname_col="model",
- groupname_col="mfr",
)
- .grand_summary_rows(fns={"min": lambda x: x.min(), "max": lambda x: x.min()}, side="top")
+ .fmt_integer(columns=["hp", "trq", "mpg_c"])
+ .grand_summary_rows(
+ fns={
+ "Min": lambda df: df.min(numeric_only=True),
+ "Max": lambda x: x.max(numeric_only=True),
+ },
+ side="top",
+ fmt=vals.fmt_integer,
+ )
.tab_style(
style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
locations=loc.grand_summary_stub(),
)
- .fmt_integer(columns=["hp", "trq", "mpg_c"])
)
```
"""
@@ -627,7 +633,7 @@ class LocGrandSummary(Loc):
rows
The rows to target. Can either be a single row name or a series of row names provided in a
list. Note that if rows are targeted by index, top and bottom grand summary rows are indexed
- as one combined list starting with the top.
+ as one combined list starting with the top rows.
Returns
-------
@@ -642,24 +648,28 @@ class LocGrandSummary(Loc):
[`tab_style()`](`great_tables.GT.tab_style`).
```{python}
- # from great_tables import GT, style, loc
- # from great_tables.data import gtcars
-
- # (
- # GT(
- # gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(5),
- # rowname_col="model",
- # groupname_col="mfr",
- # )
- # .tab_options(row_group_as_column=True)
- # .grand_summary_rows(fns=["min", "max"], side="top")
- # .grand_summary_rows(fns="mean", side="bottom")
- # .tab_style(
- # style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
- # locations=loc.grand_summary(),
- # )
- # .fmt_integer(columns=["hp", "trq", "mpg_c"])
- # )
+ from great_tables import GT, style, loc, vals
+ from great_tables.data import gtcars
+
+ (
+ GT(
+ gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(6),
+ rowname_col="model",
+ )
+ .fmt_integer(columns=["hp", "trq", "mpg_c"])
+ .grand_summary_rows(
+ fns={
+ "Min": lambda df: df.min(numeric_only=True),
+ "Max": lambda x: x.max(numeric_only=True),
+ },
+ side="top",
+ fmt=vals.fmt_integer,
+ )
+ .tab_style(
+ style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
+ locations=loc.grand_summary(),
+ )
+ )
```
"""
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index d2ef3c1a9..b432b4010 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -236,7 +236,9 @@ def grand_summary_rows(
Examples
--------
Let's use a subset of the `sp500` dataset to create a table with grand summary rows. We'll
- calculate min, max, and mean values for the numeric columns.
+ calculate min, max, and mean values for the numeric columns. Notice the different
+ approaches to selecting columns to apply the aggregations to: we can use polars selectors
+ or select the columns directly.
```{python}
import polars as pl
@@ -244,15 +246,19 @@ def grand_summary_rows(
from great_tables import GT, vals, style, loc
from great_tables.data import sp500
- sp500_mini = pl.from_pandas(sp500).slice(0, 7).drop(["volume", "adj_close"])
+ sp500_mini = (
+ pl.from_pandas(sp500)
+ .slice(0, 7)
+ .drop(["volume", "adj_close"])
+ )
(
GT(sp500_mini, rowname_col="date")
.grand_summary_rows(
fns={
- "Minimum": cs.numeric().min(),
- "Maximum": cs.numeric().max(),
- "Average": pl.mean("open", "close"),
+ "Minimum": pl.min("open", "high", "low", "close"),
+ "Maximum": pl.col("open", "high", "low", "close").max(),
+ "Average": cs.numeric().mean(),
},
fmt=vals.fmt_currency,
)
@@ -266,13 +272,38 @@ def grand_summary_rows(
)
```
- We can also use custom callable functions to create more complex summary calculations:
+ We can also use custom callable functions to create more complex summary calculations.
+ And notice here the grand summary rows can be placed at the top of the table and formatted
+ with currency notation, by passing a formatter from the `vals.fmt_*` class of functions.
+
+ ```{python}
+ from great_tables import GT, style, loc, vals
+ from great_tables.data import gtcars
- TODO pandas ex
+ def pd_median(df):
+ return df.median(numeric_only=True)
- Grand summary rows can be placed at the top of the table and formatted with currency notation:
- TODO example
+ (
+ GT(
+ gtcars[["mfr", "model", "hp", "trq", "mpg_c"]].head(6),
+ rowname_col="model",
+ )
+ .fmt_integer(columns=["hp", "trq", "mpg_c"])
+ .grand_summary_rows(
+ fns={
+ "Min": lambda df: df.min(numeric_only=True),
+ "Max": lambda df: df.max(numeric_only=True),
+ "Median": pd_median,
+ },
+ side="top",
+ fmt=vals.fmt_integer,
+ )
+ .tab_style(
+ style=[style.text(color="crimson", weight="bold"), style.fill(color="lightgray")],
+ locations=loc.grand_summary_stub(),
+ )
+ )
```
"""
From 085aef0263ecab956de6bd9555e3e73e8d15ec5c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 15:15:53 -0400
Subject: [PATCH 52/54] get started documentation
---
docs/get-started/loc-selection.qmd | 4 ++++
docs/get-started/table-theme-options.qmd | 2 ++
docs/get-started/targeted-styles.qmd | 16 +++++++++++++++-
3 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/docs/get-started/loc-selection.qmd b/docs/get-started/loc-selection.qmd
index 6222b54db..e1ead79c2 100644
--- a/docs/get-started/loc-selection.qmd
+++ b/docs/get-started/loc-selection.qmd
@@ -23,7 +23,11 @@ data = [
["", "loc.column_labels()", "columns"],
["row stub", "loc.stub()", "rows"],
["", "loc.row_groups()", "rows"],
+ # ["", "loc.summary_stub()", "rows"],
+ ["", "loc.grand_summary_stub()", "rows"],
["table body", "loc.body()", "columns and rows"],
+ # ["", "loc.summary_rows()", "columns and rows"],
+ ["", "loc.grand_summary_rows()", "columns and rows"],
["footer", "loc.footer()", "composite"],
["", "loc.source_notes()", ""],
]
diff --git a/docs/get-started/table-theme-options.qmd b/docs/get-started/table-theme-options.qmd
index ab7474444..06af6cda1 100644
--- a/docs/get-started/table-theme-options.qmd
+++ b/docs/get-started/table-theme-options.qmd
@@ -25,6 +25,7 @@ gt_ex = (
.tab_header("THE HEADING", "(a subtitle)")
.tab_stubhead("THE STUBHEAD")
.tab_source_note("THE SOURCE NOTE")
+ .grand_summary_rows({"GRAND SUMMARY ROW": lambda df: df.sum(numeric_only=True)})
)
gt_ex
@@ -48,6 +49,7 @@ The code below illustrates the table parts `~~GT.tab_options()` can target, by s
row_group_background_color="lightyellow",
stub_background_color="lightgreen",
source_notes_background_color="#f1e2af",
+ grand_summary_row_background_color="lightpink",
)
)
```
diff --git a/docs/get-started/targeted-styles.qmd b/docs/get-started/targeted-styles.qmd
index 84ac63c37..a594beba0 100644
--- a/docs/get-started/targeted-styles.qmd
+++ b/docs/get-started/targeted-styles.qmd
@@ -44,7 +44,7 @@ gt = (
.tab_source_note("yo")
.tab_spanner("spanner", ["char", "fctr"])
.tab_stubhead("stubhead")
- .grand_summary_rows(fns={"sum": lambda x: x.sum(numeric_only=True)})
+ .grand_summary_rows(fns={"Total": lambda x: x.sum(numeric_only=True)})
)
(
@@ -134,3 +134,17 @@ gt.tab_style(style.fill("yellow"), loc.body())
```{python}
gt.tab_style(style.fill("yellow"), loc.stubhead())
```
+
+## Grand Summary Rows
+
+```{python}
+(
+ gt.tab_style(
+ style.fill("yellow"),
+ loc.grand_summary_stub(),
+ ).tab_style(
+ style.fill("lightblue"),
+ loc.grand_summary(),
+ )
+)
+```
From 86a4d9f43bf639a46bc52d267fa69c15b5f76db8 Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 26 Aug 2025 15:23:18 -0400
Subject: [PATCH 53/54] docs nitpicks
---
great_tables/_locations.py | 6 +++---
great_tables/_modify_rows.py | 6 ++++--
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/great_tables/_locations.py b/great_tables/_locations.py
index 2fa58bc7b..8e91ef449 100644
--- a/great_tables/_locations.py
+++ b/great_tables/_locations.py
@@ -498,9 +498,9 @@ class LocGrandSummaryStub(Loc):
----------
rows
The rows to target within the grand summary stub. Can either be a single row name or a
- series of row names provided in a list. If no rows are specified, all rows are targeted.
- Note that if rows are targeted by index, top and bottom grand summary rows are indexed as
- one combined list starting with the top.
+ series of row names provided in a list. If no rows are specified, all grand summary rows
+ are targeted. Note that if rows are targeted by index, top and bottom grand summary rows
+ are indexed as one combined list starting with the top rows.
Returns
-------
diff --git a/great_tables/_modify_rows.py b/great_tables/_modify_rows.py
index b432b4010..8f6a31ed8 100644
--- a/great_tables/_modify_rows.py
+++ b/great_tables/_modify_rows.py
@@ -220,7 +220,9 @@ def grand_summary_rows(
`vals.fmt_currency`) to apply to the summary row values. If `None`, no formatting
is applied.
columns
- Currently, this function does not support selection by columns.
+ Currently, this function does not support selection by columns. If you would like to choose
+ which columns to summarize, you can select columns within the functions given to `fns=`.
+ See examples below for more explicit cases.
side
Should the grand summary rows be placed at the `"bottom"` (the default) or the `"top"` of
the table?
@@ -273,7 +275,7 @@ def grand_summary_rows(
```
We can also use custom callable functions to create more complex summary calculations.
- And notice here the grand summary rows can be placed at the top of the table and formatted
+ Notice here that grand summary rows can be placed at the top of the table and formatted
with currency notation, by passing a formatter from the `vals.fmt_*` class of functions.
```{python}
From 179a68faa5d4e6e9a21738151c482d82be356d1c Mon Sep 17 00:00:00 2001
From: Jules <54960783+juleswg23@users.noreply.github.com>
Date: Thu, 28 Aug 2025 12:04:53 -0400
Subject: [PATCH 54/54] remove unused attribute
---
great_tables/_gt_data.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index a3b116757..e5be1fc1c 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -723,7 +723,7 @@ class GroupRowInfo:
indices: list[int] = field(default_factory=list)
# row_start: int | None = None
# row_end: int | None = None
- has_summary_rows: bool = False # TODO: remove
+ # has_summary_rows: bool = False # TODO: remove
summary_row_side: str | None = None
def defaulted_label(self) -> str: