diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 93cc5e4210..323ef8f07a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -42,3 +42,8 @@ repos:
additional_dependencies: [types-requests, types-tabulate, types-PyYAML, pandas-stubs<=2.2.3.241126]
exclude: "^third_party"
args: ["--check-untyped-defs", "--explicit-package-bases", "--ignore-missing-imports"]
+- repo: https://github.com/biomejs/pre-commit
+ rev: v2.0.2
+ hooks:
+ - id: biome-check
+ files: '\.js$'
diff --git a/MANIFEST.in b/MANIFEST.in
index 16a933a629..e0deb6deb2 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -17,7 +17,7 @@
# Generated by synthtool. DO NOT EDIT!
include README.rst LICENSE
recursive-include third_party/bigframes_vendored *
-recursive-include bigframes *.json *.proto py.typed
+recursive-include bigframes *.json *.proto *.js py.typed
recursive-include tests *
global-exclude *.py[co]
global-exclude __pycache__
diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py
index 69b251fd5b..8e6b8efbc8 100644
--- a/bigframes/dataframe.py
+++ b/bigframes/dataframe.py
@@ -779,22 +779,7 @@ def _repr_html_(self) -> str:
if opts.repr_mode == "deferred":
return formatter.repr_query_job(self._compute_dry_run())
- if opts.repr_mode == "anywidget":
- import anywidget # type: ignore
-
- # create an iterator for the data batches
- batches = self.to_pandas_batches()
-
- # get the first page result
- try:
- first_page = next(iter(batches))
- except StopIteration:
- first_page = pandas.DataFrame(columns=self.columns)
-
- # Instantiate and return the widget. The widget's frontend will
- # handle the display of the table and pagination
- return anywidget.AnyWidget(dataframe=first_page)
-
+ # Process blob columns first, regardless of display mode
self._cached()
df = self.copy()
if bigframes.options.display.blob_display:
@@ -806,7 +791,31 @@ def _repr_html_(self) -> str:
for col in blob_cols:
# TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data.
df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True)
+ else:
+ blob_cols = []
+
+ if opts.repr_mode == "anywidget":
+ try:
+ from IPython.display import display as ipython_display
+
+ from bigframes import display
+
+ # Always create a new widget instance for each display call
+ # This ensures that each cell gets its own widget and prevents
+ # unintended sharing between cells
+ widget = display.TableWidget(df.copy())
+ ipython_display(widget)
+ return "" # Return empty string since we used display()
+
+ except (AttributeError, ValueError, ImportError):
+ # Fallback if anywidget is not available
+ warnings.warn(
+ "Anywidget mode is not available. Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. Falling back to deferred mode."
+ )
+ return formatter.repr_query_job(self._compute_dry_run())
+
+ # Continue with regular HTML rendering for non-anywidget modes
# TODO(swast): pass max_columns and get the true column count back. Maybe
# get 1 more column than we have requested so that pandas can add the
# ... for us?
@@ -815,7 +824,6 @@ def _repr_html_(self) -> str:
)
self._set_internal_query_job(query_job)
-
column_count = len(pandas_df.columns)
with display_options.pandas_repr(opts):
diff --git a/bigframes/display/__init__.py b/bigframes/display/__init__.py
new file mode 100644
index 0000000000..48e52bc766
--- /dev/null
+++ b/bigframes/display/__init__.py
@@ -0,0 +1,24 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+try:
+ import anywidget # noqa
+
+ from bigframes.display.anywidget import TableWidget
+
+ __all__ = ["TableWidget"]
+except Exception:
+ pass
diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py
new file mode 100644
index 0000000000..04d82c97fe
--- /dev/null
+++ b/bigframes/display/anywidget.py
@@ -0,0 +1,179 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from importlib import resources
+import functools
+import math
+from typing import Any, Dict, Iterator, List, Optional, Type
+import uuid
+
+import pandas as pd
+
+import bigframes
+
+# anywidget and traitlets are optional dependencies. We don't want the import of this
+# module to fail if they aren't installed, though. Instead, we try to limit the surface that
+# these packages could affect. This makes unit testing easier and ensures we don't
+# accidentally make these required packages.
+try:
+ import anywidget
+ import traitlets
+
+ ANYWIDGET_INSTALLED = True
+except Exception:
+ ANYWIDGET_INSTALLED = False
+
+WIDGET_BASE: Type[Any]
+if ANYWIDGET_INSTALLED:
+ WIDGET_BASE = anywidget.AnyWidget
+else:
+ WIDGET_BASE = object
+
+
+class TableWidget(WIDGET_BASE):
+ """
+ An interactive, paginated table widget for BigFrames DataFrames.
+ """
+
+ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
+ """Initialize the TableWidget.
+
+ Args:
+ dataframe: The Bigframes Dataframe to display in the widget.
+ """
+ if not ANYWIDGET_INSTALLED:
+ raise ImportError(
+ "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use TableWidget."
+ )
+
+ super().__init__()
+ self._dataframe = dataframe
+
+ # respect display options
+ self.page_size = bigframes.options.display.max_rows
+
+ # Initialize data fetching attributes.
+ self._batches = dataframe.to_pandas_batches(page_size=self.page_size)
+
+ # Use list of DataFrames to avoid memory copies from concatenation
+ self._cached_batches: List[pd.DataFrame] = []
+
+ # Unique identifier for HTML table element
+ self._table_id = str(uuid.uuid4())
+ self._all_data_loaded = False
+ # Renamed from _batch_iterator to _batch_iter to avoid naming conflict
+ self._batch_iter: Optional[Iterator[pd.DataFrame]] = None
+
+ # len(dataframe) is expensive, since it will trigger a
+ # SELECT COUNT(*) query. It is a must have however.
+ # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()`
+ # before we get here so that the count might already be cached.
+ self.row_count = len(dataframe)
+
+ # get the initial page
+ self._set_table_html()
+
+ @functools.cached_property
+ def _esm(self):
+ """Load JavaScript code from external file."""
+ return resources.read_text(bigframes.display, "table_widget.js")
+
+ page = traitlets.Int(0).tag(sync=True)
+ page_size = traitlets.Int(25).tag(sync=True)
+ row_count = traitlets.Int(0).tag(sync=True)
+ table_html = traitlets.Unicode().tag(sync=True)
+
+ @traitlets.validate("page")
+ def _validate_page(self, proposal: Dict[str, Any]):
+ """Validate and clamp the page number to a valid range.
+
+ Args:
+ proposal: A dictionary from the traitlets library containing the
+ proposed change. The new value is in proposal["value"].
+ """
+
+ value = proposal["value"]
+ if self.row_count == 0 or self.page_size == 0:
+ return 0
+
+ # Calculate the zero-indexed maximum page number.
+ max_page = max(0, math.ceil(self.row_count / self.page_size) - 1)
+
+ # Clamp the proposed value to the valid range [0, max_page].
+ return max(0, min(value, max_page))
+
+ def _get_next_batch(self) -> bool:
+ """
+ Gets the next batch of data from the generator and appends to cache.
+
+ Return:
+ True if a batch was successfully loaded, False otherwise.
+ """
+ if self._all_data_loaded:
+ return False
+
+ try:
+ iterator = self._batch_iterator
+ batch = next(iterator)
+ self._cached_batches.append(batch)
+ return True
+ except StopIteration:
+ self._all_data_loaded = True
+ return False
+
+ @property
+ def _batch_iterator(self) -> Iterator[pd.DataFrame]:
+ """Lazily initializes and returns the batch iterator."""
+ if self._batch_iter is None:
+ self._batch_iter = iter(self._batches)
+ return self._batch_iter
+
+ @property
+ def _cached_data(self) -> pd.DataFrame:
+ """Combine all cached batches into a single DataFrame."""
+ if not self._cached_batches:
+ return pd.DataFrame(columns=self._dataframe.columns)
+ return pd.concat(self._cached_batches, ignore_index=True)
+
+ def _set_table_html(self):
+ """Sets the current html data based on the current page and page size."""
+ start = self.page * self.page_size
+ end = start + self.page_size
+
+ # fetch more data if the requested page is outside our cache
+ cached_data = self._cached_data
+ while len(cached_data) < end and not self._all_data_loaded:
+ if self._get_next_batch():
+ cached_data = self._cached_data
+ else:
+ break
+
+ # Get the data for the current page
+ page_data = cached_data.iloc[start:end]
+
+ # Generate HTML table
+ self.table_html = page_data.to_html(
+ index=False,
+ max_rows=None,
+ table_id=f"table-{self._table_id}",
+ classes="table table-striped table-hover",
+ escape=False,
+ )
+
+ @traitlets.observe("page")
+ def _page_changed(self, change):
+ """Handler for when the page number is changed from the frontend."""
+ self._set_table_html()
diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js
new file mode 100644
index 0000000000..71484af4d5
--- /dev/null
+++ b/bigframes/display/table_widget.js
@@ -0,0 +1,95 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const ModelProperty = {
+ TABLE_HTML: "table_html",
+ ROW_COUNT: "row_count",
+ PAGE_SIZE: "page_size",
+ PAGE: "page",
+};
+
+const Event = {
+ CHANGE_TABLE_HTML: `change:${ModelProperty.TABLE_HTML}`,
+ CLICK: "click",
+};
+
+/**
+ * Renders a paginated table and its controls into a given element.
+ * @param {{
+ * model: !Backbone.Model,
+ * el: !HTMLElement
+ * }} options
+ */
+function render({ model, el }) {
+ const container = document.createElement("div");
+ container.innerHTML = model.get(ModelProperty.TABLE_HTML);
+
+ const buttonContainer = document.createElement("div");
+ const prevPage = document.createElement("button");
+ const label = document.createElement("span");
+ const nextPage = document.createElement("button");
+
+ prevPage.type = "button";
+ nextPage.type = "button";
+ prevPage.textContent = "Prev";
+ nextPage.textContent = "Next";
+
+ /** Updates the button states and page label based on the model. */
+ function updateButtonStates() {
+ const totalPages = Math.ceil(
+ model.get(ModelProperty.ROW_COUNT) / model.get(ModelProperty.PAGE_SIZE),
+ );
+ const currentPage = model.get(ModelProperty.PAGE);
+
+ label.textContent = `Page ${currentPage + 1} of ${totalPages}`;
+ prevPage.disabled = currentPage === 0;
+ nextPage.disabled = currentPage >= totalPages - 1;
+ }
+
+ /**
+ * Updates the page in the model.
+ * @param {number} direction -1 for previous, 1 for next.
+ */
+ function handlePageChange(direction) {
+ const currentPage = model.get(ModelProperty.PAGE);
+ const newPage = Math.max(0, currentPage + direction);
+ if (newPage !== currentPage) {
+ model.set(ModelProperty.PAGE, newPage);
+ model.save_changes();
+ }
+ }
+
+ prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1));
+ nextPage.addEventListener(Event.CLICK, () => handlePageChange(1));
+
+ model.on(Event.CHANGE_TABLE_HTML, () => {
+ // Note: Using innerHTML can be a security risk if the content is
+ // user-generated. Ensure 'table_html' is properly sanitized.
+ container.innerHTML = model.get(ModelProperty.TABLE_HTML);
+ updateButtonStates();
+ });
+
+ // Initial setup
+ updateButtonStates();
+
+ buttonContainer.appendChild(prevPage);
+ buttonContainer.appendChild(label);
+ buttonContainer.appendChild(nextPage);
+ el.appendChild(container);
+ el.appendChild(buttonContainer);
+}
+
+export default { render };
diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb
index c54f52da59..072e5c6504 100644
--- a/notebooks/dataframes/anywidget_mode.ipynb
+++ b/notebooks/dataframes/anywidget_mode.ipynb
@@ -63,7 +63,7 @@
"id": "0a354c69",
"metadata": {},
"source": [
- "Display the dataframe in anywidget mode"
+ "Load Sample Data"
]
},
{
@@ -75,7 +75,7 @@
{
"data": {
"text/html": [
- "Query job 91997f19-1768-4360-afa7-4a431b3e2d22 is DONE. 0 Bytes processed. Open Job"
+ "Query job 0b22b0f5-b952-4546-a969-41a89e343e9b is DONE. 0 Bytes processed. Open Job"
],
"text/plain": [
""
@@ -123,6 +123,193 @@
"test_series = df[\"year\"]\n",
"print(test_series)"
]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7bcf1bb7",
+ "metadata": {},
+ "source": [
+ "Display with Pagination"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "ce250157",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "Query job 8e57da45-b6a7-44fb-8c4f-4b87058d94cb is DONE. 171.4 MB processed. Open Job"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "4d00aaf284984cbc97483c651b9c5110",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ "TableWidget(row_count=5552452, table_html='=0.9.18",
+ "traitlets>=5.0.0",
],
}
extras["all"] = list(sorted(frozenset(itertools.chain.from_iterable(extras.values()))))
diff --git a/testing/constraints-3.9.txt b/testing/constraints-3.9.txt
index dff245d176..155d4388a4 100644
--- a/testing/constraints-3.9.txt
+++ b/testing/constraints-3.9.txt
@@ -33,3 +33,6 @@ pytz==2022.7
toolz==0.11
typing-extensions==4.5.0
rich==12.4.4
+# For anywidget mode
+anywidget>=0.9.18
+traitlets==5.0.0
diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py
new file mode 100644
index 0000000000..b6dfb22934
--- /dev/null
+++ b/tests/system/small/test_anywidget.py
@@ -0,0 +1,343 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pandas as pd
+import pytest
+
+import bigframes as bf
+
+pytest.importorskip("anywidget")
+
+# Test constants to avoid change detector tests
+EXPECTED_ROW_COUNT = 6
+EXPECTED_PAGE_SIZE = 2
+EXPECTED_TOTAL_PAGES = 3
+
+
+@pytest.fixture(scope="module")
+def paginated_pandas_df() -> pd.DataFrame:
+ """Create a minimal test DataFrame with exactly 3 pages of 2 rows each."""
+ test_data = pd.DataFrame(
+ {
+ "id": [0, 1, 2, 3, 4, 5],
+ "page_indicator": [
+ # Page 1 (rows 1-2)
+ "page_1_row_1",
+ "page_1_row_2",
+ # Page 2 (rows 3-4)
+ "page_2_row_1",
+ "page_2_row_2",
+ # Page 3 (rows 5-6)
+ "page_3_row_1",
+ "page_3_row_2",
+ ],
+ "value": [0, 1, 2, 3, 4, 5],
+ }
+ )
+ return test_data
+
+
+@pytest.fixture(scope="module")
+def paginated_bf_df(
+ session: bf.Session, paginated_pandas_df: pd.DataFrame
+) -> bf.dataframe.DataFrame:
+ return session.read_pandas(paginated_pandas_df)
+
+
+@pytest.fixture
+def table_widget(paginated_bf_df: bf.dataframe.DataFrame):
+ """
+ Helper fixture to create a TableWidget instance with a fixed page size.
+ This reduces duplication across tests that use the same widget configuration.
+ """
+ from bigframes import display
+
+ with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
+ # Delay context manager cleanup of `max_rows` until after tests finish.
+ yield display.TableWidget(paginated_bf_df)
+
+
+@pytest.fixture(scope="module")
+def small_pandas_df() -> pd.DataFrame:
+ """Create a DataFrame smaller than the page size for edge case testing."""
+ return pd.DataFrame(
+ {
+ "id": [0, 1],
+ "page_indicator": ["small_row_1", "small_row_2"],
+ "value": [0, 1],
+ }
+ )
+
+
+@pytest.fixture(scope="module")
+def small_bf_df(
+ session: bf.Session, small_pandas_df: pd.DataFrame
+) -> bf.dataframe.DataFrame:
+ return session.read_pandas(small_pandas_df)
+
+
+@pytest.fixture
+def small_widget(small_bf_df):
+ """Helper fixture for tests using a DataFrame smaller than the page size."""
+ from bigframes import display
+
+ with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 5):
+ yield display.TableWidget(small_bf_df)
+
+
+@pytest.fixture(scope="module")
+def empty_pandas_df() -> pd.DataFrame:
+ """Create an empty DataFrame for edge case testing."""
+ return pd.DataFrame(columns=["id", "page_indicator", "value"])
+
+
+@pytest.fixture(scope="module")
+def empty_bf_df(
+ session: bf.Session, empty_pandas_df: pd.DataFrame
+) -> bf.dataframe.DataFrame:
+ return session.read_pandas(empty_pandas_df)
+
+
+def _assert_html_matches_pandas_slice(
+ table_html: str,
+ expected_pd_slice: pd.DataFrame,
+ full_pd_df: pd.DataFrame,
+):
+ """
+ Assertion helper to verify that the rendered HTML contains exactly the
+ rows from the expected pandas DataFrame slice and no others. This is
+ inspired by the pattern of comparing BigFrames output to pandas output.
+ """
+ # Check that the unique indicator from each expected row is present.
+ for _, row in expected_pd_slice.iterrows():
+ assert row["page_indicator"] in table_html
+
+ # Create a DataFrame of all rows that should NOT be present.
+ unexpected_pd_df = full_pd_df.drop(expected_pd_slice.index)
+
+ # Check that no unique indicators from unexpected rows are present.
+ for _, row in unexpected_pd_df.iterrows():
+ assert row["page_indicator"] not in table_html
+
+
+def test_widget_initialization_should_calculate_total_row_count(
+ paginated_bf_df: bf.dataframe.DataFrame,
+):
+ """A TableWidget should correctly calculate the total row count on creation."""
+ from bigframes import display
+
+ with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
+ widget = display.TableWidget(paginated_bf_df)
+
+ assert widget.row_count == EXPECTED_ROW_COUNT
+
+
+def test_widget_initialization_should_set_default_pagination(
+ table_widget,
+):
+ """A TableWidget should initialize with page 0 and the correct page size."""
+ # The `table_widget` fixture already creates the widget.
+ # Assert its state.
+ assert table_widget.page == 0
+ assert table_widget.page_size == EXPECTED_PAGE_SIZE
+
+
+def test_widget_display_should_show_first_page_on_load(
+ table_widget, paginated_pandas_df: pd.DataFrame
+):
+ """
+ Given a widget, when it is first loaded, then it should display
+ the first page of data.
+ """
+ expected_slice = paginated_pandas_df.iloc[0:2]
+
+ html = table_widget.table_html
+
+ _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df)
+
+
+def test_widget_navigation_should_display_second_page(
+ table_widget, paginated_pandas_df: pd.DataFrame
+):
+ """
+ Given a widget, when the page is set to 1, then it should display
+ the second page of data.
+ """
+ expected_slice = paginated_pandas_df.iloc[2:4]
+
+ table_widget.page = 1
+ html = table_widget.table_html
+
+ assert table_widget.page == 1
+ _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df)
+
+
+def test_widget_navigation_should_display_last_page(
+ table_widget, paginated_pandas_df: pd.DataFrame
+):
+ """
+ Given a widget, when the page is set to the last page (2),
+ then it should display the final page of data.
+ """
+ expected_slice = paginated_pandas_df.iloc[4:6]
+
+ table_widget.page = 2
+ html = table_widget.table_html
+
+ assert table_widget.page == 2
+ _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df)
+
+
+def test_widget_navigation_should_clamp_to_zero_for_negative_input(
+ table_widget, paginated_pandas_df: pd.DataFrame
+):
+ """
+ Given a widget, when a negative page number is set,
+ then the page number should be clamped to 0 and display the first page.
+ """
+ expected_slice = paginated_pandas_df.iloc[0:2]
+
+ table_widget.page = -1
+ html = table_widget.table_html
+
+ assert table_widget.page == 0
+ _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df)
+
+
+def test_widget_navigation_should_clamp_to_last_page_for_out_of_bounds_input(
+ table_widget, paginated_pandas_df: pd.DataFrame
+):
+ """
+ Given a widget, when a page number greater than the max is set,
+ then the page number should be clamped to the last valid page.
+ """
+ expected_slice = paginated_pandas_df.iloc[4:6]
+
+ table_widget.page = 100
+ html = table_widget.table_html
+
+ assert table_widget.page == 2
+ _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df)
+
+
+@pytest.mark.parametrize(
+ "page, start_row, end_row",
+ [
+ (0, 0, 3), # Page 0: rows 0-2
+ (1, 3, 6), # Page 1: rows 3-5
+ ],
+ ids=[
+ "Page 0 (Rows 0-2)",
+ "Page 1 (Rows 3-5)",
+ ],
+)
+def test_widget_pagination_should_work_with_custom_page_size(
+ paginated_bf_df: bf.dataframe.DataFrame,
+ paginated_pandas_df: pd.DataFrame,
+ page: int,
+ start_row: int,
+ end_row: int,
+):
+ """
+ A widget should paginate correctly with a custom page size of 3.
+ """
+ with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 3):
+ from bigframes.display import TableWidget
+
+ widget = TableWidget(paginated_bf_df)
+ assert widget.page_size == 3
+
+ expected_slice = paginated_pandas_df.iloc[start_row:end_row]
+
+ widget.page = page
+ html = widget.table_html
+
+ assert widget.page == page
+ _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df)
+
+
+def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas_df):
+ """
+ Given a DataFrame smaller than the page size, the widget should
+ display all rows on the first page.
+ """
+ html = small_widget.table_html
+
+ _assert_html_matches_pandas_slice(html, small_pandas_df, small_pandas_df)
+
+
+def test_widget_with_few_rows_should_have_only_one_page(small_widget):
+ """
+ Given a DataFrame smaller than the page size, the widget should
+ clamp page navigation, effectively having only one page.
+ """
+ assert small_widget.page == 0
+
+ # Attempt to navigate past the end
+ small_widget.page = 1
+
+ # Should be clamped back to the only valid page
+ assert small_widget.page == 0
+
+
+def test_widget_page_size_should_be_immutable_after_creation(
+ paginated_bf_df: bf.dataframe.DataFrame,
+):
+ """
+ A widget's page size should be fixed on creation and not be affected
+ by subsequent changes to global options.
+ """
+ with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
+ from bigframes.display import TableWidget
+
+ widget = TableWidget(paginated_bf_df)
+ assert widget.page_size == 2
+
+ # Navigate to second page to ensure widget is in a non-default state
+ widget.page = 1
+ assert widget.page == 1
+
+ # Change global max_rows - widget should not be affected
+ bf.options.display.max_rows = 10
+
+ assert widget.page_size == 2 # Should remain unchanged
+ assert widget.page == 1 # Should remain on same page
+
+
+def test_empty_widget_should_have_zero_row_count(empty_bf_df: bf.dataframe.DataFrame):
+ """Given an empty DataFrame, the widget's row count should be 0."""
+ with bf.option_context("display.repr_mode", "anywidget"):
+ from bigframes.display import TableWidget
+
+ widget = TableWidget(empty_bf_df)
+
+ assert widget.row_count == 0
+
+
+def test_empty_widget_should_render_table_headers(empty_bf_df: bf.dataframe.DataFrame):
+ """Given an empty DataFrame, the widget should still render table headers."""
+ with bf.option_context("display.repr_mode", "anywidget"):
+ from bigframes.display import TableWidget
+
+ widget = TableWidget(empty_bf_df)
+
+ html = widget.table_html
+
+ assert "