Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
88fcf76
feat: set up Angular infrastructure for TableWidget
shuoweil May 4, 2026
2393021
feat: implement DOM sanitization in Angular bridge
shuoweil May 4, 2026
49b7977
feat: address code review comments and add license headers
shuoweil May 4, 2026
2b6a9fb
test: test pre-commit hook after noxfile fix
shuoweil May 5, 2026
b959885
Merge branch 'main' into shuowei-angular
shuoweil May 8, 2026
1e0990f
Alphabetize CSS declarations in app.ts
shuoweil May 8, 2026
2e3de3d
Merge branch 'main' into shuowei-angular
shuoweil May 19, 2026
3cc2770
chore: clean up angular boilerplate and add copyright headers
shuoweil May 19, 2026
65dce43
Merge branch 'main' into shuowei-angular
shuoweil May 19, 2026
c31f5a5
feat: rewrite TableWidget core in Angular
shuoweil May 19, 2026
3b4f7d7
fix(display): cast JSON and nested struct columns to string for anywi…
shuoweil May 19, 2026
cef1518
format code
shuoweil May 19, 2026
efe189d
Merge branch 'main' into shuowei-angular-rewrite-core
shuoweil May 19, 2026
5282e6d
opt(display): batch df.assign calls for json display serialization
shuoweil May 19, 2026
b3c5577
format code
shuoweil May 19, 2026
8a60c13
Merge branch 'main' into shuowei-angular
shuoweil May 20, 2026
609f1a7
Merge branch 'main' into shuowei-angular
shuoweil May 22, 2026
d984db9
Merge branch 'shuowei-angular' into shuowei-angular-rewrite-core
shuoweil May 22, 2026
3c2c0d7
fix(display): update test_html.py unit test for display refactoring
shuoweil May 22, 2026
86e9842
refactor(display): rename display function to _process_display_df
shuoweil May 22, 2026
205bcab
feat: support deferred execution rendering
shuoweil May 22, 2026
00eed75
style: make run button black and white and remove title
shuoweil May 22, 2026
f8b5728
fix: initialize TableWidget traitlets after super init
shuoweil May 22, 2026
7432a18
fix: truth value and cast bugs in deferred execution mode
shuoweil May 27, 2026
37829b2
Merge branch 'main' into shuowei-angular-deferred-mode
shuoweil Jun 12, 2026
2ffc540
Fix Angular bootstrap by providing zoneless change detection
shuoweil Jun 5, 2026
4a298f8
fix: use createApplication to bootstrap widget on element
shuoweil Jun 10, 2026
40e6a80
docs: rerun notebook
shuoweil Jun 10, 2026
dd828ae
test: add unit test for angular widget bootstrap
shuoweil Jun 10, 2026
1a198e1
test: clean up redundant comments in test
shuoweil Jun 10, 2026
e9402b7
Update packages/bigframes/bigframes/display/table_widget_angular/src/…
shuoweil Jun 10, 2026
9fcd378
Update packages/bigframes/bigframes/display/table_widget_angular/src/…
shuoweil Jun 10, 2026
65fb27a
Update packages/bigframes/bigframes/display/table_widget_angular/src/…
shuoweil Jun 10, 2026
c64448b
Update packages/bigframes/bigframes/display/table_widget_angular/src/…
shuoweil Jun 10, 2026
f11d3d0
fix: address table widget angular code review comments
shuoweil Jun 11, 2026
1deac9a
format
shuoweil Jun 11, 2026
e37c383
style: format table widget angular and tests to 80-char limit
shuoweil Jun 11, 2026
2d5cb42
chore: rebuild table_widget_angular.js
shuoweil Jun 12, 2026
8a1bfdf
fix: execute query asynchronously in TableWidget to avoid IPython ker…
shuoweil Jun 12, 2026
ad88989
fix: support multiple widget instances by using dynamic attribute sel…
shuoweil Jun 12, 2026
21c29f6
Merge branch 'main' into shuowei-angular-deferred-mode
shuoweil Jun 16, 2026
6be4131
style: fix style guide violations
shuoweil Jun 16, 2026
289ffde
test: add JS unit test for deferred execution mode
shuoweil Jun 16, 2026
2a9c49a
ui: stabilize widget container size to prevent layout shift
shuoweil Jun 16, 2026
13d22a4
ui: implement dynamic height locking from legacy widget
shuoweil Jun 16, 2026
a477a4f
fix: resolve unused variable and duplicate test redefinition
shuoweil Jun 16, 2026
97af548
format
shuoweil Jun 16, 2026
a3dd2f6
revert: move sqlglot fix to separate branch
shuoweil Jun 16, 2026
38529ed
fix: resolve deferred mode display & thread execution reviews
shuoweil Jun 16, 2026
50179a0
format
shuoweil Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/bigframes/bigframes/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ def __repr__(self) -> str:
column_count=len(self.columns),
)

def _prepare_display_df(self) -> DataFrame:
def _process_display_df(self) -> tuple[DataFrame, list[str]]:
"""Process ObjectRef and JSON/nested JSON columns for display."""
df = self
# Arrow/Pandas to_pandas_batches does not support raw JSON/nested JSON
Expand All @@ -837,7 +837,7 @@ def _prepare_display_df(self) -> DataFrame:
sql_template="TO_JSON_STRING({0})",
)
df = df.assign(**{col: df[col]._apply_unary_op(op) for col in json_cols})
return df
return df, []

def _repr_mimebundle_(self, include=None, exclude=None):
"""
Expand Down
103 changes: 94 additions & 9 deletions packages/bigframes/bigframes/display/anywidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

import dataclasses
import functools
import logging

logger = logging.getLogger(__name__)
import math
import threading
import uuid
Expand Down Expand Up @@ -77,8 +80,18 @@ class TableWidget(_WIDGET_BASE):
_error_message = traitlets.Unicode(allow_none=True, default_value=None).tag(
sync=True
)

def __init__(self, dataframe: bigframes.dataframe.DataFrame):
start_execution = traitlets.Bool(False).tag(sync=True)
is_deferred_mode = traitlets.Bool(False).tag(sync=True)
dry_run_info = traitlets.Unicode("").tag(sync=True)

def __init__(
self,
dataframe: (
bigframes.dataframe.DataFrame
| bigframes.session.deferred.DeferredBigQueryDataFrame
),
dry_run_info: Optional[str] = None,
):
"""Initialize the TableWidget.

Args:
Expand All @@ -90,14 +103,34 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
"`pip install 'bigframes[anywidget]'` to use TableWidget."
)

self._dataframe = dataframe
from bigframes.session import deferred

is_deferred = False
deferred_df = None
df = None

if isinstance(dataframe, deferred.DeferredBigQueryDataFrame):
is_deferred = True
deferred_df = dataframe
elif bigframes.options.display.repr_mode == "deferred":
is_deferred = True
df = dataframe
else:
df = dataframe

from bigframes.core.utils import get_ipython_execution_count

self._cell_execution_count = get_ipython_execution_count()

super().__init__()

self.is_deferred_mode = is_deferred
self._deferred_dataframe = deferred_df
self._dataframe = df

if dry_run_info:
self.dry_run_info = dry_run_info

# Initialize attributes that might be needed by observers first
self._table_id = str(uuid.uuid4())
self._all_data_loaded = False
Expand All @@ -111,19 +144,60 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
initial_page_size = bigframes.options.display.max_rows
initial_max_columns = bigframes.options.display.max_columns

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

self.orderable_columns = self._get_orderable_columns(dataframe)

self._initial_load()
if not self.is_deferred_mode:
self._initialize_from_dataframe()

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

@traitlets.observe("start_execution")
def _on_start_execution(self, change: dict[str, Any]):
if change["new"]:

def run_execution():
try:
self._error_message = None
if self.is_deferred_mode:
if self._deferred_dataframe is not None:
result = self._deferred_dataframe.execute()
if isinstance(result, bigframes.series.Series):
df = result.to_frame()
elif isinstance(result, bigframes.dataframe.DataFrame):
df = result
else:
raise TypeError(
f"Unexpected result type: {type(result)}"
)
self._dataframe, _ = df._process_display_df()
self._initialize_from_dataframe()
self.is_deferred_mode = False
elif self._dataframe is not None:
self._dataframe, _ = self._dataframe._process_display_df()
self._initialize_from_dataframe()
self.is_deferred_mode = False
elif not self.is_deferred_mode and self._dataframe is not None:
self._initial_load()
except Exception as e:
logger.warning(f"Error in background execution: {e}")
self._error_message = str(e)
finally:
self.start_execution = False
Comment on lines +161 to +188

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are a few issues in the background execution thread:

  1. If the execution fails, self.is_deferred_mode is set to False in the finally block. This transitions the widget out of deferred mode prematurely, resulting in an empty table display and a confusing 'Internal Error: DataFrame is missing' message. Instead, self.is_deferred_mode should only be set to False upon successful execution and initialization.
  2. If a previous execution failed, self._error_message is not cleared when starting a new execution, so the old error message will remain visible while the new query is running. We should clear self._error_message at the start of run_execution.
  3. Avoid broad except Exception: blocks that silently handle errors without logging. We should log the exception using logger.warning to aid in debugging.
            def run_execution():
                try:
                    self._error_message = None
                    if self.is_deferred_mode:
                        if self._deferred_dataframe is not None:
                            result = self._deferred_dataframe.execute()
                            if isinstance(result, bigframes.series.Series):
                                df = result.to_frame()
                            else:
                                df = result
                            self._dataframe, _ = df._process_display_df()
                            self._initialize_from_dataframe()
                            self.is_deferred_mode = False
                        elif self._dataframe is not None:
                            self._dataframe, _ = self._dataframe._process_display_df()
                            self._initialize_from_dataframe()
                            self.is_deferred_mode = False
                    elif not self.is_deferred_mode and self._dataframe is not None:
                        self._initial_load()
                except Exception as e:
                    logger.warning(f"Error in background execution: {e}")
                    self._error_message = str(e)
                finally:
                    self.start_execution = False
References
  1. Avoid broad except Exception: blocks that silently return None. Instead, log the exception (e.g., using logger.warning) to aid in debugging and prevent masking underlying issues.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Reset _error_message to None at the start of execution, transitioned is_deferred_mode to False only on successful execution/initialization, and added a logger.warning for background execution exceptions. Also updated test assertions to verify this behavior.


self._execution_thread = threading.Thread(target=run_execution, daemon=True)
self._execution_thread.start()

def _initialize_from_dataframe(self):
if self._dataframe is None:
return

self.orderable_columns = self._get_orderable_columns(self._dataframe)

self._initial_load()

def _get_orderable_columns(
self, dataframe: bigframes.dataframe.DataFrame
) -> list[str]:
Expand Down Expand Up @@ -278,7 +352,9 @@ def _batch_iterator(self) -> Iterator[pd.DataFrame]:
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)
if self._dataframe is not None:
return pd.DataFrame(columns=self._dataframe.columns)
return pd.DataFrame()
return pd.concat(self._cached_batches)

def _reset_batch_cache(self) -> None:
Expand All @@ -289,6 +365,8 @@ def _reset_batch_cache(self) -> None:

def _reset_batches_for_new_page_size(self) -> None:
"""Reset the batch iterator when page size changes."""
if self._dataframe is None:
return
with bigframes.option_context("display.progress_bar", None):
self._batches = self._dataframe.to_pandas_batches(
page_size=self.page_size,
Expand All @@ -299,6 +377,9 @@ def _reset_batches_for_new_page_size(self) -> None:

def _set_table_html(self) -> None:
"""Sets the current html data based on the current page and page size."""
if self.is_deferred_mode:
return

new_page = None
with (
self._setting_html_lock,
Expand All @@ -310,6 +391,10 @@ def _set_table_html(self) -> None:
)
return

if self._dataframe is None:
self.table_html = "<div class='bigframes-error-message'>Internal Error: DataFrame is missing.</div>"
return

# Apply sorting if a column is selected
df_to_display = self._dataframe
sort_columns = [item["column"] for item in self.sort_context]
Expand Down
75 changes: 50 additions & 25 deletions packages/bigframes/bigframes/display/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ def get_anywidget_bundle(
obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series],
include=None,
exclude=None,
dry_run_info: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Helper method to create and return the anywidget mimebundle.
Expand All @@ -244,9 +245,17 @@ def get_anywidget_bundle(
else:
df = obj

df = df._prepare_display_df()
from bigframes.session import deferred

widget = display.TableWidget(df)
if (
not isinstance(df, deferred.DeferredBigQueryDataFrame)
and bigframes.options.display.repr_mode != "deferred"
):
display_df, _ = df._process_display_df()
else:
display_df = df

widget = display.TableWidget(display_df, dry_run_info=dry_run_info)
widget_repr_result = widget._repr_mimebundle_(include=include, exclude=exclude)

if isinstance(widget_repr_result, tuple):
Expand All @@ -262,20 +271,23 @@ def get_anywidget_bundle(
total_rows = widget.row_count
total_columns = len(df.columns)

widget_repr["text/html"] = create_html_representation(
obj,
cached_pd,
total_rows,
total_columns,
)
is_series, has_index = _get_obj_metadata(obj)
widget_repr["text/plain"] = plaintext.create_text_representation(
cached_pd,
total_rows,
is_series=is_series,
has_index=has_index,
column_count=len(df.columns) if not is_series else 0,
)
if dry_run_info:
widget_repr["text/plain"] = dry_run_info
else:
widget_repr["text/html"] = create_html_representation(
obj,
cached_pd,
total_rows,
total_columns,
)
is_series, has_index = _get_obj_metadata(obj)
widget_repr["text/plain"] = plaintext.create_text_representation(
cached_pd,
total_rows,
is_series=is_series,
has_index=has_index,
column_count=len(df.columns) if not is_series else 0,
)

return widget_repr, widget_metadata

Expand All @@ -300,7 +312,7 @@ def repr_mimebundle_head(
else:
df = obj

df = df._prepare_display_df()
df, _ = df._process_display_df()
pandas_df, row_count, query_job = df._block.retrieve_repr_request_results(
opts.max_rows
)
Expand Down Expand Up @@ -332,27 +344,40 @@ def repr_mimebundle(
# BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed.

opts = options.display
if opts.repr_mode == "deferred":
return repr_mimebundle_deferred(obj)

if opts.render_mode == "anywidget" or opts.repr_mode == "anywidget":
if (
opts.render_mode == "anywidget"
or opts.repr_mode == "anywidget"
or opts.repr_mode == "deferred"
):
try:
with bigframes.option_context("display.progress_bar", None):
with warnings.catch_warnings():
warnings.simplefilter(
"ignore", category=bigframes.exceptions.JSONDtypeWarning
)
warnings.simplefilter("ignore", category=FutureWarning)
return get_anywidget_bundle(obj, include=include, exclude=exclude)
except ImportError:
dry_run_info = None
if opts.repr_mode == "deferred":
dry_run_job = obj._compute_dry_run()
dry_run_info = formatter.repr_query_job(dry_run_job)
return get_anywidget_bundle(
obj,
include=include,
exclude=exclude,
dry_run_info=dry_run_info,
)
except Exception:
# Anywidget is an optional dependency, so warn rather than fail.
# TODO(shuowei): When Anywidget becomes the default for all repr modes,
# remove this warning.
warnings.warn(
"Anywidget mode is not available. "
"Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. "
"Anywidget mode is not available or failed to load. "
"Please `pip install anywidget traitlets` or "
"`pip install 'bigframes[anywidget]'` to use interactive tables. "
f"Falling back to static HTML. Error: {traceback.format_exc()}"
)
if opts.repr_mode == "deferred":
return repr_mimebundle_deferred(obj)

bundle = repr_mimebundle_head(obj)
if opts.render_mode == "plaintext":
Expand Down
Loading
Loading