diff --git a/bigframes/display/__init__.py b/bigframes/display/__init__.py index 48e52bc766..97248a0efb 100644 --- a/bigframes/display/__init__.py +++ b/bigframes/display/__init__.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Interactive display objects for BigQuery DataFrames.""" + from __future__ import annotations try: diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index d61c4691c8..4c0b9d64ee 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Interactive, paginated table widget for BigFrames DataFrames.""" + from __future__ import annotations from importlib import resources diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index fab8b54efb..c1df0a2927 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -96,7 +96,7 @@ function render({ model, el }) { // Known total rows const totalPages = Math.ceil(rowCount / pageSize); rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; - paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${rowCount.toLocaleString()}`; + paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; prevPage.disabled = currentPage === 0; nextPage.disabled = currentPage >= totalPages - 1; } diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index f7a4b0e2d6..e810377dd7 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -142,7 +142,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8fcad7b7e408422cae71d519cd2d4980", + "model_id": "388323f1f2394f44a7199e7ecda7b5d2", "version_major": 2, "version_minor": 1 }, @@ -166,7 +166,7 @@ } ], "source": [ - "df.set_index(\"name\")" + "df" ] }, { @@ -205,7 +205,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "06cb98c577514d5c9654a7792d93f8e6", + "model_id": "24847f9deca94a2abb4fa1a64ec8b616", "version_major": 2, "version_minor": 1 }, @@ -305,7 +305,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "1672f826f7a347e38539dbb5fb72cd43", + "model_id": "8b8a0b2a3572442f8718baa08657bef6", "version_major": 2, "version_minor": 1 }, @@ -345,7 +345,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 12 seconds of slot time.\n", + " Query processed 85.9 kB in 15 seconds of slot time.\n", " " ], "text/plain": [ @@ -380,7 +380,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "127a2e356b834c18b6f07c58ee2c4228", + "model_id": "1327a3901c194bf3a03f7f3a3358dc19", "version_major": 2, "version_minor": 1 }, @@ -415,93 +415,6 @@ " LIMIT 5;\n", "\"\"\")" ] - }, - { - "cell_type": "markdown", - "id": "multi-index-display-markdown", - "metadata": {}, - "source": [ - "## Display Multi-Index DataFrame in anywidget mode\n", - "This section demonstrates how BigFrames can display a DataFrame with multiple levels of indexing (a \"multi-index\") when using the `anywidget` display mode." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "ad7482aa", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "✅ Completed. \n", - " Query processed 483.3 GB in 51 minutes of slot time. [Job bigframes-dev:US.3eace7c0-7776-48d6-925c-965be33d8738 details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "✅ Completed. \n", - " Query processed 124.4 MB in 7 seconds of slot time. [Job bigframes-dev:US.job_UJ5cx4R1jW5cNxq_1H1x-9-ATfqS details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "3f9652b5fdc0441eac2b05ab36d571d0", - "version_major": 2, - "version_minor": 1 - }, - "text/plain": [ - "TableWidget(page_size=10, row_count=3967869, table_html=' seven_days_ago]\n", - " \n", - "# Create a multi-index by grouping by date and project\n", - "pypi_df_recent['date'] = pypi_df_recent['timestamp'].dt.date\n", - "multi_index_df = pypi_df_recent.groupby([\"date\", \"project\"]).size().to_frame(\"downloads\")\n", - " \n", - "# Display the DataFrame with the multi-index\n", - "multi_index_df" - ] } ], "metadata": { diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 99734dc30c..c7b957891b 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -13,6 +13,9 @@ # limitations under the License. +from typing import Any +from unittest import mock + import pandas as pd import pytest @@ -98,6 +101,33 @@ def small_widget(small_bf_df): yield TableWidget(small_bf_df) +@pytest.fixture +def unknown_row_count_widget(session): + """Fixture to create a TableWidget with an unknown row count.""" + from bigframes.core import blocks + from bigframes.display import TableWidget + + # Create a small DataFrame with known content + test_data = pd.DataFrame( + { + "id": [0, 1, 2, 3, 4], + "value": ["row_0", "row_1", "row_2", "row_3", "row_4"], + } + ) + bf_df = session.read_pandas(test_data) + + # Simulate a scenario where total_rows is not available from the iterator + with mock.patch.object(bf_df, "_to_pandas_batches") as mock_batches: + # We need to provide an iterator of DataFrames, not Series + batches_iterator = iter([test_data]) + mock_batches.return_value = blocks.PandasBatches( + batches_iterator, total_rows=None + ) + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(bf_df) + yield widget + + @pytest.fixture(scope="module") def empty_pandas_df() -> pd.DataFrame: """Create an empty DataFrame for edge case testing.""" @@ -129,7 +159,7 @@ def execution_metadata(self) -> ExecutionMetadata: return ExecutionMetadata() @property - def schema(self): + def schema(self) -> Any: return schema def batches(self) -> ResultsIterator: @@ -180,7 +210,9 @@ def test_widget_initialization_should_calculate_total_row_count( def test_widget_initialization_should_set_default_pagination( table_widget, ): - """A TableWidget should initialize with page 0 and the correct page size.""" + """ + 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 @@ -304,15 +336,20 @@ def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas 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. + Given a DataFrame with a small number of rows, the widget should + report the correct total row count and prevent navigation beyond + the first page, ensuring the frontend correctly displays "Page 1 of 1". """ + # For a DataFrame with 2 rows and page_size 5 (from small_widget fixture), + # the frontend should calculate 1 total page. + assert small_widget.row_count == 2 + + # The widget should always be on page 0 for a single-page dataset. assert small_widget.page == 0 - # Attempt to navigate past the end + # Attempting to navigate to page 1 should be clamped back to page 0, + # confirming that only one page is recognized by the backend. small_widget.page = 1 - - # Should be clamped back to the only valid page assert small_widget.page == 0 @@ -420,8 +457,10 @@ def test_navigation_after_page_size_change_should_use_new_size( @pytest.mark.parametrize("invalid_size", [0, -5], ids=["zero", "negative"]) def test_setting_invalid_page_size_should_be_ignored(table_widget, invalid_size: int): - """When the page size is set to an invalid number (<=0), the change should - be ignored.""" + """ + When the page size is set to an invalid number (<=0), the change should + be ignored. + """ # Set the initial page to 2. initial_size = table_widget.page_size assert initial_size == 2