Skip to content

Commit 421eebd

Browse files
feat: support interactive execution of deferred DataFrames in TableWidget (#17486)
This PR introduces support for deferred execution rendering in TableWidget (the anywidget-based interactive table viewer for Jupyter notebooks). Users can now view dry-run estimations (e.g., query size/cost) and trigger execution directly from the notebook output. Verified at: vs code: go/scrcast/NjcyODQxMDY2MjQzNjg2NHxlNjJhY2Y0NS1hMg colab notebook: go/scrcast/NTk1NzEyMTg0NDcwNzMyOHxjZGQ1ZjQ0NS05NQ Fixes #<460865443> 🦕 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 3619b29 commit 421eebd

17 files changed

Lines changed: 2101 additions & 8138 deletions

File tree

packages/bigframes/bigframes/display/anywidget.py

Lines changed: 226 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
import dataclasses
2020
import functools
21+
import logging
22+
23+
logger = logging.getLogger(__name__)
2124
import math
2225
import threading
2326
import uuid
@@ -58,6 +61,19 @@ class _SortState:
5861
ascending: tuple[bool, ...]
5962

6063

64+
@dataclasses.dataclass
65+
class _ExecutionResult:
66+
df_to_set: Optional[bigframes.dataframe.DataFrame] = None
67+
orderable_cols: Optional[list[str]] = None
68+
batches: Optional[blocks.PandasBatches] = None
69+
batch_iter: Optional[Iterator[pd.DataFrame]] = None
70+
cached_batches: Optional[list[pd.DataFrame]] = None
71+
all_data_loaded: bool = False
72+
total_rows: Optional[int] = None
73+
initial_html: Optional[str] = None
74+
error_message: Optional[str] = None
75+
76+
6177
class TableWidget(_WIDGET_BASE):
6278
"""An interactive, paginated table widget for BigFrames DataFrames.
6379
@@ -77,8 +93,19 @@ class TableWidget(_WIDGET_BASE):
7793
_error_message = traitlets.Unicode(allow_none=True, default_value=None).tag(
7894
sync=True
7995
)
80-
81-
def __init__(self, dataframe: bigframes.dataframe.DataFrame):
96+
start_execution = traitlets.Bool(False).tag(sync=True)
97+
is_deferred_mode = traitlets.Bool(False).tag(sync=True)
98+
dry_run_info = traitlets.Unicode("").tag(sync=True)
99+
ping = traitlets.Int(0).tag(sync=True)
100+
101+
def __init__(
102+
self,
103+
dataframe: (
104+
bigframes.dataframe.DataFrame
105+
| bigframes.session.deferred.DeferredBigQueryDataFrame
106+
),
107+
dry_run_info: Optional[str] = None,
108+
):
82109
"""Initialize the TableWidget.
83110
84111
Args:
@@ -90,40 +117,219 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
90117
"`pip install 'bigframes[anywidget]'` to use TableWidget."
91118
)
92119

93-
self._dataframe = dataframe
120+
# Enable third-party widgets manager in Google Colab environment.
121+
try:
122+
import sys
123+
124+
if "google.colab" in sys.modules:
125+
from google.colab import output
126+
127+
output.enable_custom_widget_manager()
128+
except Exception:
129+
pass
130+
131+
from bigframes.session import deferred
132+
133+
is_deferred = False
134+
deferred_df = None
135+
df = None
136+
137+
if isinstance(dataframe, deferred.DeferredBigQueryDataFrame):
138+
is_deferred = True
139+
deferred_df = dataframe
140+
elif bigframes.options.display.repr_mode == "deferred":
141+
is_deferred = True
142+
df = dataframe
143+
else:
144+
df = dataframe
94145

95146
from bigframes.core.utils import get_ipython_execution_count
96147

97148
self._cell_execution_count = get_ipython_execution_count()
98149

99150
super().__init__()
100151

152+
self.is_deferred_mode = is_deferred
153+
self._deferred_dataframe = deferred_df
154+
self._dataframe = df
155+
156+
if dry_run_info:
157+
self.dry_run_info = dry_run_info
158+
101159
# Initialize attributes that might be needed by observers first
102160
self._table_id = str(uuid.uuid4())
103161
self._all_data_loaded = False
104162
self._batch_iter: Optional[Iterator[pd.DataFrame]] = None
105163
self._cached_batches: list[pd.DataFrame] = []
106164
self._last_sort_state: Optional[_SortState] = None
165+
self._execution_result: Optional[_ExecutionResult] = None
107166
# Lock to ensure only one thread at a time is updating the table HTML.
108167
self._setting_html_lock = threading.Lock()
109168

110169
# respect display options for initial page size
111170
initial_page_size = bigframes.options.display.max_rows
112171
initial_max_columns = bigframes.options.display.max_columns
113172

114-
# set traitlets properties that trigger observers
115-
# TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns.
116173
self.page_size = initial_page_size
117174
self.max_columns = initial_max_columns
118175

119-
self.orderable_columns = self._get_orderable_columns(dataframe)
120-
121-
self._initial_load()
176+
if not self.is_deferred_mode:
177+
self._initialize_from_dataframe()
122178

123179
# Signals to the frontend that the initial data load is complete.
124180
# Also used as a guard to prevent observers from firing during initialization.
125181
self._initial_load_complete = True
126182

183+
@traitlets.observe("start_execution")
184+
def _on_start_execution(self, change: dict[str, Any]):
185+
if change["new"]:
186+
import asyncio
187+
188+
try:
189+
loop = asyncio.get_running_loop()
190+
except RuntimeError:
191+
try:
192+
import tornado.ioloop # type: ignore[import-not-found]
193+
194+
loop = tornado.ioloop.IOLoop.current().asyncio_loop # type: ignore[attr-defined]
195+
except Exception:
196+
loop = None
197+
198+
def run_execution():
199+
try:
200+
self._error_message = None
201+
df = None
202+
if self.is_deferred_mode:
203+
if self._deferred_dataframe is not None:
204+
result = self._deferred_dataframe.execute()
205+
if isinstance(result, bigframes.series.Series):
206+
df = result.to_frame()
207+
elif isinstance(result, bigframes.dataframe.DataFrame):
208+
df = result
209+
else:
210+
raise TypeError(
211+
f"Unexpected result type: {type(result)}"
212+
)
213+
elif self._dataframe is not None:
214+
df = self._dataframe
215+
else:
216+
df = self._dataframe
217+
218+
if df is None:
219+
raise ValueError("No DataFrame to execute.")
220+
221+
df_to_set = df._prepare_display_df()
222+
orderable_cols = self._get_orderable_columns(df_to_set)
223+
224+
with bigframes.option_context("display.progress_bar", None):
225+
batches = df_to_set.to_pandas_batches(
226+
page_size=self.page_size,
227+
cell_execution_count=self._cell_execution_count,
228+
)
229+
230+
total_rows = getattr(batches, "total_rows", None)
231+
232+
# Fetch the first batch
233+
batch_iter = iter(batches)
234+
try:
235+
initial_batch = next(batch_iter)
236+
cached_batches = [initial_batch]
237+
all_data_loaded = False
238+
except StopIteration:
239+
initial_batch = pd.DataFrame(columns=df_to_set.columns)
240+
cached_batches = []
241+
all_data_loaded = True
242+
243+
# Render the HTML
244+
page_data = initial_batch.copy()
245+
start = 0
246+
if df_to_set._block.has_index:
247+
is_unnamed_single_index = (
248+
page_data.index.name is None
249+
and not isinstance(page_data.index, pd.MultiIndex)
250+
)
251+
page_data = page_data.reset_index()
252+
if is_unnamed_single_index and "index" in page_data.columns:
253+
page_data.rename(columns={"index": ""}, inplace=True)
254+
else:
255+
page_data.insert(
256+
0, "Row", range(start + 1, start + len(page_data) + 1)
257+
)
258+
259+
initial_html = bigframes.display.html.render_html(
260+
dataframe=page_data,
261+
table_id=f"table-{self._table_id}",
262+
orderable_columns=orderable_cols,
263+
max_columns=self.max_columns,
264+
)
265+
266+
self._execution_result = _ExecutionResult(
267+
df_to_set=df_to_set,
268+
orderable_cols=orderable_cols,
269+
batches=batches,
270+
batch_iter=batch_iter,
271+
cached_batches=cached_batches,
272+
all_data_loaded=all_data_loaded,
273+
total_rows=total_rows,
274+
initial_html=initial_html,
275+
)
276+
except Exception as e:
277+
logger.warning(f"Error in background execution: {e}")
278+
self._execution_result = _ExecutionResult(error_message=str(e))
279+
280+
import sys
281+
282+
is_colab = "google.colab" in sys.modules
283+
284+
if loop is not None and loop.is_running() and not is_colab:
285+
loop.call_soon_threadsafe(self._apply_execution_result)
286+
elif is_colab:
287+
# In Google Colab, background thread updates to traitlets are not automatically
288+
# synchronized to the frontend. We rely on the frontend's active pinging
289+
# (which triggers `_on_ping` on the main kernel thread) to apply the result.
290+
pass
291+
else:
292+
self._apply_execution_result()
293+
294+
self._execution_thread = threading.Thread(target=run_execution, daemon=True)
295+
self._execution_thread.start()
296+
297+
def _apply_execution_result(self) -> None:
298+
if self._execution_result is None:
299+
return
300+
301+
result = self._execution_result
302+
self._execution_result = None
303+
304+
with self.hold_sync():
305+
if result.error_message is not None:
306+
self._error_message = result.error_message
307+
self.start_execution = False
308+
else:
309+
self._dataframe = result.df_to_set
310+
self.orderable_columns = result.orderable_cols or []
311+
self._batches = result.batches
312+
self._batch_iter = result.batch_iter
313+
self._cached_batches = result.cached_batches or []
314+
self._all_data_loaded = result.all_data_loaded
315+
self._last_sort_state = _SortState((), ())
316+
self.row_count = result.total_rows
317+
self.table_html = result.initial_html or ""
318+
self.is_deferred_mode = False
319+
self.start_execution = False
320+
321+
@traitlets.observe("ping")
322+
def _on_ping(self, _change: dict[str, Any]):
323+
self._apply_execution_result()
324+
325+
def _initialize_from_dataframe(self):
326+
if self._dataframe is None:
327+
return
328+
329+
self.orderable_columns = self._get_orderable_columns(self._dataframe)
330+
331+
self._initial_load()
332+
127333
def _get_orderable_columns(
128334
self, dataframe: bigframes.dataframe.DataFrame
129335
) -> list[str]:
@@ -278,7 +484,9 @@ def _batch_iterator(self) -> Iterator[pd.DataFrame]:
278484
def _cached_data(self) -> pd.DataFrame:
279485
"""Combine all cached batches into a single DataFrame."""
280486
if not self._cached_batches:
281-
return pd.DataFrame(columns=self._dataframe.columns)
487+
if self._dataframe is not None:
488+
return pd.DataFrame(columns=self._dataframe.columns)
489+
return pd.DataFrame()
282490
return pd.concat(self._cached_batches)
283491

284492
def _reset_batch_cache(self) -> None:
@@ -289,6 +497,8 @@ def _reset_batch_cache(self) -> None:
289497

290498
def _reset_batches_for_new_page_size(self) -> None:
291499
"""Reset the batch iterator when page size changes."""
500+
if self._dataframe is None:
501+
return
292502
with bigframes.option_context("display.progress_bar", None):
293503
self._batches = self._dataframe.to_pandas_batches(
294504
page_size=self.page_size,
@@ -299,6 +509,9 @@ def _reset_batches_for_new_page_size(self) -> None:
299509

300510
def _set_table_html(self) -> None:
301511
"""Sets the current html data based on the current page and page size."""
512+
if self.is_deferred_mode:
513+
return
514+
302515
new_page = None
303516
with (
304517
self._setting_html_lock,
@@ -310,6 +523,10 @@ def _set_table_html(self) -> None:
310523
)
311524
return
312525

526+
if self._dataframe is None:
527+
self.table_html = "<div class='bigframes-error-message'>Internal Error: DataFrame is missing.</div>"
528+
return
529+
313530
# Apply sorting if a column is selected
314531
df_to_display = self._dataframe
315532
sort_columns = [item["column"] for item in self.sort_context]

0 commit comments

Comments
 (0)