Skip to content

Commit 0cf84ec

Browse files
committed
feat: Display index in anywidget table
1 parent 0d7d7e4 commit 0cf84ec

File tree

3 files changed

+225
-10
lines changed

3 files changed

+225
-10
lines changed

bigframes/display/anywidget.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def _cached_data(self) -> pd.DataFrame:
197197
"""Combine all cached batches into a single DataFrame."""
198198
if not self._cached_batches:
199199
return pd.DataFrame(columns=self._dataframe.columns)
200-
return pd.concat(self._cached_batches, ignore_index=True)
200+
return pd.concat(self._cached_batches)
201201

202202
def _reset_batches_for_new_page_size(self) -> None:
203203
"""Reset the batch iterator when page size changes."""
@@ -227,7 +227,7 @@ def _set_table_html(self) -> None:
227227
break
228228

229229
# Get the data for the current page
230-
page_data = cached_data.iloc[start:end]
230+
page_data = cached_data[start:end]
231231

232232
# Generate HTML table
233233
self.table_html = bigframes.display.html.render_html(

bigframes/display/html.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ def render_html(
4242
# Render table head
4343
table_html.append(" <thead>")
4444
table_html.append(' <tr style="text-align: left;">')
45+
46+
# Add index headers
47+
for name in dataframe.index.names:
48+
table_html.append(
49+
f' <th style="text-align: left;"><div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">{html.escape(str(name))}</div></th>'
50+
)
51+
4552
for col in dataframe.columns:
4653
table_html.append(
4754
f' <th style="text-align: left;"><div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">{html.escape(str(col))}</div></th>'
@@ -51,18 +58,27 @@ def render_html(
5158

5259
# Render table body
5360
table_html.append(" <tbody>")
54-
for i in range(len(dataframe)):
61+
for row_tuple in dataframe.itertuples():
5562
table_html.append(" <tr>")
56-
row = dataframe.iloc[i]
57-
for col_name, value in row.items():
63+
# First item in itertuples is the index, which can be a tuple for MultiIndex
64+
index_values = row_tuple[0]
65+
if not isinstance(index_values, tuple):
66+
index_values = (index_values,)
67+
68+
for value in index_values:
69+
table_html.append(' <td style="text-align: left; padding: 0.5em;">')
70+
table_html.append(f" {html.escape(str(value))}")
71+
table_html.append(" </td>")
72+
73+
# The rest are the column values
74+
for i, value in enumerate(row_tuple[1:]):
75+
col_name = dataframe.columns[i]
5876
dtype = dataframe.dtypes.loc[col_name] # type: ignore
5977
align = "right" if _is_dtype_numeric(dtype) else "left"
6078
table_html.append(
6179
' <td style="text-align: {}; padding: 0.5em;">'.format(align)
6280
)
6381

64-
# TODO(b/438181139): Consider semi-exploding ARRAY/STRUCT columns
65-
# into multiple rows/columns like the BQ UI does.
6682
if pandas.api.types.is_scalar(value) and pd.isna(value):
6783
table_html.append(' <em style="color: gray;">&lt;NA&gt;</em>')
6884
else:

tests/system/small/test_anywidget.py

Lines changed: 202 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,54 @@ def empty_bf_df(
111111
return session.read_pandas(empty_pandas_df)
112112

113113

114+
@pytest.fixture(scope="module")
115+
def custom_index_pandas_df() -> pd.DataFrame:
116+
"""Create a DataFrame with a custom named index for testing."""
117+
test_data = pd.DataFrame(
118+
{
119+
"value_a": [10, 20, 30, 40, 50, 60],
120+
"value_b": ["a", "b", "c", "d", "e", "f"],
121+
}
122+
)
123+
test_data.index = pd.Index(
124+
["row_1", "row_2", "row_3", "row_4", "row_5", "row_6"], name="custom_idx"
125+
)
126+
return test_data
127+
128+
129+
@pytest.fixture(scope="module")
130+
def custom_index_bf_df(
131+
session: bf.Session, custom_index_pandas_df: pd.DataFrame
132+
) -> bf.dataframe.DataFrame:
133+
return session.read_pandas(custom_index_pandas_df)
134+
135+
136+
@pytest.fixture(scope="module")
137+
def multiindex_pandas_df() -> pd.DataFrame:
138+
"""Create a DataFrame with MultiIndex for testing."""
139+
test_data = pd.DataFrame(
140+
{
141+
"value": [100, 200, 300, 400, 500, 600],
142+
"category": ["X", "Y", "Z", "X", "Y", "Z"],
143+
}
144+
)
145+
test_data.index = pd.MultiIndex.from_arrays(
146+
[
147+
["group_A", "group_A", "group_A", "group_B", "group_B", "group_B"],
148+
[1, 2, 3, 1, 2, 3],
149+
],
150+
names=["group", "item"],
151+
)
152+
return test_data
153+
154+
155+
@pytest.fixture(scope="module")
156+
def multiindex_bf_df(
157+
session: bf.Session, multiindex_pandas_df: pd.DataFrame
158+
) -> bf.dataframe.DataFrame:
159+
return session.read_pandas(multiindex_pandas_df)
160+
161+
114162
def mock_execute_result_with_params(
115163
self, schema, total_rows_val, arrow_batches_val, *args, **kwargs
116164
):
@@ -549,6 +597,157 @@ def test_widget_row_count_reflects_actual_data_available(
549597
assert widget.page_size == 2 # Respects the display option
550598

551599

552-
# TODO(shuowei): Add tests for custom index and multiindex
553-
# This may not be necessary for the SQL Cell use case but should be
554-
# considered for completeness.
600+
def test_widget_with_custom_index_should_display_index_column(
601+
custom_index_bf_df: bf.dataframe.DataFrame,
602+
custom_index_pandas_df: pd.DataFrame,
603+
):
604+
"""
605+
Given a DataFrame with a custom named index, when rendered in anywidget mode,
606+
then the index column should be visible in the HTML output.
607+
"""
608+
from bigframes.display import TableWidget
609+
610+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
611+
widget = TableWidget(custom_index_bf_df)
612+
html = widget.table_html
613+
614+
assert "custom_idx" in html
615+
assert "row_1" in html
616+
assert "row_2" in html
617+
assert "row_3" not in html
618+
assert "row_4" not in html
619+
620+
621+
def test_widget_with_custom_index_pagination_preserves_index(
622+
custom_index_bf_df: bf.dataframe.DataFrame,
623+
custom_index_pandas_df: pd.DataFrame,
624+
):
625+
"""
626+
Given a DataFrame with a custom index, when navigating between pages,
627+
then each page should display the correct index values.
628+
"""
629+
from bigframes.display import TableWidget
630+
631+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
632+
widget = TableWidget(custom_index_bf_df)
633+
634+
widget.page = 1
635+
html = widget.table_html
636+
637+
assert "row_3" in html
638+
assert "row_4" in html
639+
assert "row_1" not in html
640+
assert "row_2" not in html
641+
642+
643+
def test_widget_with_custom_index_matches_pandas_output(
644+
custom_index_bf_df: bf.dataframe.DataFrame,
645+
custom_index_pandas_df: pd.DataFrame,
646+
):
647+
"""
648+
Given a DataFrame with a custom index, the widget's HTML output should
649+
match what pandas would render for the same slice.
650+
"""
651+
from bigframes.display import TableWidget
652+
653+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 3):
654+
widget = TableWidget(custom_index_bf_df)
655+
html = widget.table_html
656+
657+
expected_slice = custom_index_pandas_df.iloc[0:3]
658+
659+
for idx_value in expected_slice.index:
660+
assert str(idx_value) in html
661+
662+
663+
def test_widget_with_multiindex_should_display_all_index_levels(
664+
multiindex_bf_df: bf.dataframe.DataFrame,
665+
multiindex_pandas_df: pd.DataFrame,
666+
):
667+
"""
668+
Given a DataFrame with MultiIndex, when rendered in anywidget mode,
669+
then all index levels should be visible in the HTML output.
670+
"""
671+
from bigframes.display import TableWidget
672+
673+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
674+
widget = TableWidget(multiindex_bf_df)
675+
html = widget.table_html
676+
677+
assert "group" in html
678+
assert "item" in html
679+
assert "group_A" in html
680+
681+
682+
def test_widget_with_multiindex_pagination_preserves_structure(
683+
multiindex_bf_df: bf.dataframe.DataFrame,
684+
multiindex_pandas_df: pd.DataFrame,
685+
):
686+
"""
687+
Given a DataFrame with MultiIndex, when navigating between pages,
688+
then the multiindex structure should be preserved on each page.
689+
"""
690+
from bigframes.display import TableWidget
691+
692+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
693+
widget = TableWidget(multiindex_bf_df)
694+
695+
widget.page = 1
696+
html = widget.table_html
697+
698+
assert "group_A" in html or "3" in html
699+
assert "group_B" in html
700+
701+
702+
def test_widget_with_multiindex_all_pages_have_correct_indices(
703+
multiindex_bf_df: bf.dataframe.DataFrame,
704+
multiindex_pandas_df: pd.DataFrame,
705+
):
706+
"""
707+
Given a DataFrame with MultiIndex, verify that each page displays
708+
the correct multiindex values across all pages.
709+
"""
710+
from bigframes.display import TableWidget
711+
712+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
713+
widget = TableWidget(multiindex_bf_df)
714+
715+
for page_num in range(3):
716+
widget.page = page_num
717+
html = widget.table_html
718+
719+
start_row = page_num * 2
720+
end_row = start_row + 2
721+
expected_slice = multiindex_pandas_df.iloc[start_row:end_row]
722+
723+
found_index_value = False
724+
for idx_tuple in expected_slice.index:
725+
if str(idx_tuple[0]) in html or str(idx_tuple[1]) in html:
726+
found_index_value = True
727+
break
728+
729+
assert found_index_value, f"Page {page_num} missing expected index values"
730+
731+
732+
def test_widget_with_multiindex_page_size_change_preserves_structure(
733+
multiindex_bf_df: bf.dataframe.DataFrame,
734+
multiindex_pandas_df: pd.DataFrame,
735+
):
736+
"""
737+
Given a DataFrame with MultiIndex, when the page size is changed,
738+
then the multiindex structure should still be correctly displayed.
739+
"""
740+
from bigframes.display import TableWidget
741+
742+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
743+
widget = TableWidget(multiindex_bf_df)
744+
745+
widget.page_size = 3
746+
html = widget.table_html
747+
748+
assert "group" in html
749+
assert "item" in html
750+
751+
expected_slice = multiindex_pandas_df.iloc[0:3]
752+
for idx_tuple in expected_slice.index:
753+
assert str(idx_tuple[0]) in html or str(idx_tuple[1]) in html

0 commit comments

Comments
 (0)