Skip to content

Commit 61be008

Browse files
authored
fix(table): allow freezing pandas index columns (#9631)
1 parent 92a02ee commit 61be008

2 files changed

Lines changed: 120 additions & 6 deletions

File tree

marimo/_plugins/ui/_impl/table.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,7 @@ def __init__(
756756
search_result_raw_data: str | None = None
757757
field_types: FieldTypes | None = None
758758
num_columns = 0
759+
row_headers = self._manager.get_row_headers()
759760

760761
if not _internal_lazy:
761762
# Search first page
@@ -776,8 +777,12 @@ def __init__(
776777
# Validate column configurations
777778
column_names_set = set(self._manager.get_column_names())
778779
num_columns = len(column_names_set)
780+
row_header_names_set = {name for name, _ in row_headers}
779781
_validate_frozen_columns(
780-
freeze_columns_left, freeze_columns_right, column_names_set
782+
freeze_columns_left,
783+
freeze_columns_right,
784+
column_names_set,
785+
row_header_names_set,
781786
)
782787
_validate_column_formatting(
783788
text_justify_columns, wrapped_columns, column_names_set
@@ -816,7 +821,7 @@ def __init__(
816821
"show-page-size-selector": show_page_size_selector,
817822
"show-column-explorer": show_column_explorer,
818823
"show-chart-builder": show_chart_builder,
819-
"row-headers": self._manager.get_row_headers(),
824+
"row-headers": row_headers,
820825
"freeze-columns-left": freeze_columns_left,
821826
"freeze-columns-right": freeze_columns_right,
822827
"text-justify-columns": text_justify_columns,
@@ -1748,12 +1753,16 @@ def _validate_frozen_columns(
17481753
freeze_columns_left: Sequence[str] | None,
17491754
freeze_columns_right: Sequence[str] | None,
17501755
column_names_set: set[str],
1756+
row_header_names_set: set[str],
17511757
) -> None:
17521758
"""Validate frozen column configurations.
17531759
17541760
Validates that:
17551761
1. The same column is not frozen on both sides
1756-
2. All frozen columns exist in the table
1762+
2. All left-frozen columns exist as table columns or row-header names
1763+
3. Right-frozen columns exist as table columns; row-header names are
1764+
rejected with a friendly error since row headers always render on
1765+
the left
17571766
"""
17581767

17591768
freeze_columns_left_set = (
@@ -1763,20 +1772,37 @@ def _validate_frozen_columns(
17631772
set(freeze_columns_right) if freeze_columns_right else None
17641773
)
17651774

1766-
# Convert sequences to sets for O(1) lookups
17671775
if freeze_columns_left_set and freeze_columns_right_set:
17681776
if not freeze_columns_left_set.isdisjoint(freeze_columns_right_set):
17691777
raise ValueError("The same column cannot be frozen on both sides.")
17701778

1771-
# Check all frozen columns exist
17721779
if freeze_columns_left_set:
1773-
invalid = freeze_columns_left_set - column_names_set
1780+
# Unnamed row headers (e.g. a default pandas index) have no stable
1781+
# client-side id, so we can't freeze them. Surface this directly
1782+
# rather than letting the frontend silently no-op.
1783+
if "" in freeze_columns_left_set and "" in row_header_names_set:
1784+
raise ValueError(
1785+
"Cannot freeze an unnamed row index. "
1786+
"Set `df.index.name = '...'` (or `df.index.names = [...]` "
1787+
"for a MultiIndex) and pass that name to freeze_columns_left."
1788+
)
1789+
invalid = (
1790+
freeze_columns_left_set - column_names_set - row_header_names_set
1791+
)
17741792
if invalid:
17751793
raise ValueError(
17761794
f"Column '{next(iter(invalid))}' not found in table."
17771795
)
17781796

17791797
if freeze_columns_right_set:
1798+
row_header_on_right = freeze_columns_right_set & row_header_names_set
1799+
if row_header_on_right:
1800+
name = next(iter(row_header_on_right))
1801+
raise ValueError(
1802+
f"Row index '{name}' cannot be frozen on the right; "
1803+
"row headers always render on the left. "
1804+
"Use freeze_columns_left instead."
1805+
)
17801806
invalid = freeze_columns_right_set - column_names_set
17811807
if invalid:
17821808
raise ValueError(

tests/_plugins/ui/_impl/test_table.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,94 @@ def test_table_with_frozen_columns() -> None:
11121112
assert table._component_args["freeze-columns-right"] == ["d", "e"]
11131113

11141114

1115+
@pytest.mark.skipif(
1116+
not DependencyManager.pandas.has(), reason="Pandas not installed"
1117+
)
1118+
class TestFrozenRowHeaders:
1119+
def test_freeze_unnamed_pandas_index_rejected(self) -> None:
1120+
import pandas as pd
1121+
1122+
df = pd.DataFrame({"a": [1, 2, 3]}, index=["x", "y", "z"])
1123+
with pytest.raises(ValueError, match="unnamed row index"):
1124+
ui.table(df, freeze_columns_left=[""])
1125+
1126+
def test_freeze_named_pandas_index(self) -> None:
1127+
import pandas as pd
1128+
1129+
df = pd.DataFrame(
1130+
{"a": [1, 2]}, index=pd.Index(["x", "y"], name="foo")
1131+
)
1132+
table = ui.table(df, freeze_columns_left=["foo"])
1133+
assert table._component_args["freeze-columns-left"] == ["foo"]
1134+
1135+
def test_freeze_multiindex_levels(self) -> None:
1136+
import pandas as pd
1137+
1138+
df = pd.DataFrame(
1139+
{"v": [1, 2, 3, 4]},
1140+
index=pd.MultiIndex.from_tuples(
1141+
[("a", 1), ("a", 2), ("b", 1), ("b", 2)], names=["g", "n"]
1142+
),
1143+
)
1144+
table = ui.table(df, freeze_columns_left=["g", "n"])
1145+
assert table._component_args["freeze-columns-left"] == ["g", "n"]
1146+
1147+
def test_freeze_collision_suffixed_index(self) -> None:
1148+
import pandas as pd
1149+
1150+
# Index name 'a' collides with a column named 'a'; the row-header
1151+
# name is suffixed to '_index' (see _resolve_index_name).
1152+
df = pd.DataFrame({"a": [1, 2]}, index=pd.Index(["x", "y"], name="a"))
1153+
table = ui.table(df, freeze_columns_left=["a_index"])
1154+
assert table._component_args["freeze-columns-left"] == ["a_index"]
1155+
1156+
def test_freeze_index_and_column_mixed(self) -> None:
1157+
import pandas as pd
1158+
1159+
df = pd.DataFrame(
1160+
{"a": [1, 2], "b": [3, 4]},
1161+
index=pd.Index(["x", "y"], name="foo"),
1162+
)
1163+
table = ui.table(
1164+
df,
1165+
freeze_columns_left=["foo", "a"],
1166+
freeze_columns_right=["b"],
1167+
)
1168+
assert table._component_args["freeze-columns-left"] == ["foo", "a"]
1169+
assert table._component_args["freeze-columns-right"] == ["b"]
1170+
1171+
def test_freeze_row_header_on_right_raises(self) -> None:
1172+
import pandas as pd
1173+
1174+
df = pd.DataFrame(
1175+
{"a": [1, 2]}, index=pd.Index(["x", "y"], name="foo")
1176+
)
1177+
with pytest.raises(
1178+
ValueError, match="row headers always render on the left"
1179+
):
1180+
ui.table(df, freeze_columns_right=["foo"])
1181+
1182+
def test_freeze_unknown_column_still_raises(self) -> None:
1183+
import pandas as pd
1184+
1185+
df = pd.DataFrame(
1186+
{"a": [1, 2]}, index=pd.Index(["x", "y"], name="foo")
1187+
)
1188+
with pytest.raises(ValueError, match="not found in table"):
1189+
ui.table(df, freeze_columns_left=["nonexistent"])
1190+
1191+
1192+
@pytest.mark.skipif(
1193+
not DependencyManager.polars.has(), reason="Polars not installed"
1194+
)
1195+
def test_freeze_columns_polars_regression() -> None:
1196+
import polars as pl
1197+
1198+
df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
1199+
table = ui.table(df, freeze_columns_left=["a"])
1200+
assert table._component_args["freeze-columns-left"] == ["a"]
1201+
1202+
11151203
@pytest.mark.parametrize(
11161204
"df",
11171205
create_dataframes({"a": [1, 2, 3], "b": ["abc", "def", None]}),

0 commit comments

Comments
 (0)