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 "