Skip to content

Commit 876f8ae

Browse files
committed
fix: Robust optional dependency handling for TableWidget and consolidated JS tests
1 parent 2285d0f commit 876f8ae

File tree

4 files changed

+183
-149
lines changed

4 files changed

+183
-149
lines changed

bigframes/display/anywidget.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,78 @@
4444
except Exception:
4545
_ANYWIDGET_INSTALLED = False
4646

47+
class _DummyTraitlet:
48+
def __init__(self, default_value=None):
49+
self.default_value = default_value
50+
self.name = None
51+
52+
def tag(self, **kwargs):
53+
return self
54+
55+
def __set_name__(self, owner, name):
56+
self.name = name
57+
58+
def __get__(self, instance, owner):
59+
if instance is None:
60+
return self
61+
return instance.__dict__.get(self.name, self.default_value)
62+
63+
def __set__(self, instance, value):
64+
# Basic mimic of traitlets validation/observation
65+
# Look for validators registered via @validate
66+
for attr_name in dir(instance):
67+
attr = getattr(type(instance), attr_name, None)
68+
if (
69+
attr is not None
70+
and hasattr(attr, "_validated_trait")
71+
and attr._validated_trait == self.name
72+
):
73+
value = getattr(instance, attr_name)({"value": value})
74+
75+
instance.__dict__[self.name] = value
76+
77+
# Look for observers registered via @observe
78+
for attr_name in dir(instance):
79+
attr = getattr(type(instance), attr_name, None)
80+
if (
81+
attr is not None
82+
and hasattr(attr, "_observed_trait")
83+
and attr._observed_trait == self.name
84+
):
85+
getattr(instance, attr_name)({"new": value})
86+
87+
class _DummyTraitletsModule:
88+
def Int(self, default_value=0, **kwargs):
89+
return _DummyTraitlet(default_value)
90+
91+
def Unicode(self, default_value="", **kwargs):
92+
return _DummyTraitlet(default_value)
93+
94+
def List(self, trait=None, default_value=None, **kwargs):
95+
return _DummyTraitlet(default_value if default_value is not None else [])
96+
97+
def Bool(self, default_value=False, **kwargs):
98+
return _DummyTraitlet(default_value)
99+
100+
def Dict(self, *args, **kwargs):
101+
return _DummyTraitlet({})
102+
103+
def observe(self, trait_name, **kwargs):
104+
def decorator(func):
105+
func._observed_trait = trait_name
106+
return func
107+
108+
return decorator
109+
110+
def validate(self, trait_name, **kwargs):
111+
def decorator(func):
112+
func._validated_trait = trait_name
113+
return func
114+
115+
return decorator
116+
117+
traitlets = _DummyTraitletsModule() # type: ignore[assignment]
118+
47119
_WIDGET_BASE: type[Any]
48120
if _ANYWIDGET_INSTALLED:
49121
_WIDGET_BASE = anywidget.AnyWidget

tests/js/table_widget.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,4 +528,92 @@ describe('TableWidget', () => {
528528
expect(model.save_changes).toHaveBeenCalled();
529529
});
530530
});
531+
532+
describe('Deferred Mode UI', () => {
533+
it('should show deferred message and hide table when in deferred mode', () => {
534+
// Mock deferred mode = true
535+
model.get.mockImplementation((property) => {
536+
if (property === 'is_deferred_mode') return true;
537+
if (property === 'table_html') return '<table></table>';
538+
return null;
539+
});
540+
541+
render({ model, el });
542+
543+
const deferredContainer = el.querySelector('.deferred-message');
544+
const tableContainer = el.querySelector('.table-container');
545+
const footer = el.querySelector('.footer');
546+
547+
expect(deferredContainer.style.display).toBe('flex');
548+
expect(tableContainer.style.display).toBe('none');
549+
expect(footer.style.display).toBe('none');
550+
expect(deferredContainer.textContent).toContain(
551+
'This is a preview of the widget',
552+
);
553+
});
554+
555+
it('should show table and hide deferred message when not in deferred mode', () => {
556+
// Mock deferred mode = false
557+
model.get.mockImplementation((property) => {
558+
if (property === 'is_deferred_mode') return false;
559+
if (property === 'table_html') return '<table></table>';
560+
return null;
561+
});
562+
563+
render({ model, el });
564+
565+
const deferredContainer = el.querySelector('.deferred-message');
566+
const tableContainer = el.querySelector('.table-container');
567+
const footer = el.querySelector('.footer');
568+
569+
expect(deferredContainer.style.display).toBe('none');
570+
expect(tableContainer.style.display).toBe('block');
571+
expect(footer.style.display).toBe('flex');
572+
});
573+
574+
it('should trigger start_execution when run button is clicked', () => {
575+
model.get.mockImplementation((property) => {
576+
if (property === 'is_deferred_mode') return true;
577+
return null;
578+
});
579+
580+
render({ model, el });
581+
582+
const runButton = el.querySelector('.run-button');
583+
runButton.click();
584+
585+
expect(model.set).toHaveBeenCalledWith('start_execution', true);
586+
expect(model.save_changes).toHaveBeenCalled();
587+
expect(runButton.textContent).toBe('Running...');
588+
expect(runButton.disabled).toBe(true);
589+
});
590+
591+
it('should update UI when is_deferred_mode changes', () => {
592+
// Start in deferred mode
593+
let isDeferred = true;
594+
model.get.mockImplementation((property) => {
595+
if (property === 'is_deferred_mode') return isDeferred;
596+
if (property === 'table_html') return '<table></table>';
597+
return null;
598+
});
599+
600+
render({ model, el });
601+
602+
const deferredContainer = el.querySelector('.deferred-message');
603+
const tableContainer = el.querySelector('.table-container');
604+
605+
expect(deferredContainer.style.display).toBe('flex');
606+
expect(tableContainer.style.display).toBe('none');
607+
608+
// Change to non-deferred mode
609+
isDeferred = false;
610+
const changeHandler = model.on.mock.calls.find(
611+
(call) => call[0] === 'change:is_deferred_mode',
612+
)[1];
613+
changeHandler();
614+
615+
expect(deferredContainer.style.display).toBe('none');
616+
expect(tableContainer.style.display).toBe('block');
617+
});
618+
});
531619
});

tests/js/table_widget_deferred.test.js

Lines changed: 0 additions & 129 deletions
This file was deleted.

tests/unit/display/test_anywidget.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,29 @@
2222

2323
@pytest.fixture
2424
def mock_df():
25-
df = mock.Mock()
26-
# Mock behavior for caching check (shape)
27-
df.shape = (100, 4)
28-
df.columns = ["A", "B", "C", "D"]
29-
# Use actual bigframes dtypes or compatible types that works with is_orderable
30-
df.dtypes = {
31-
"A": bigframes.dtypes.INT_DTYPE,
32-
"B": bigframes.dtypes.STRING_DTYPE,
33-
"C": bigframes.dtypes.FLOAT_DTYPE,
34-
"D": bigframes.dtypes.BOOL_DTYPE,
35-
}
36-
37-
# Ensure to_pandas_batches returns an iterable
38-
df.to_pandas_batches.return_value = iter(
39-
[pd.DataFrame({"A": [1], "B": ["a"], "C": [1.0], "D": [True]})]
40-
)
41-
42-
# Ensure sort_values returns the mock itself (so to_pandas_batches is still configured)
43-
df.sort_values.return_value = df
44-
return df
25+
# Mock _ANYWIDGET_INSTALLED to True so we can test the class logic
26+
# even when anywidget isn't installed in the test environment.
27+
with mock.patch("bigframes.display.anywidget._ANYWIDGET_INSTALLED", True):
28+
df = mock.Mock()
29+
# Mock behavior for caching check (shape)
30+
df.shape = (100, 4)
31+
df.columns = ["A", "B", "C", "D"]
32+
# Use actual bigframes dtypes or compatible types that works with is_orderable
33+
df.dtypes = {
34+
"A": bigframes.dtypes.INT_DTYPE,
35+
"B": bigframes.dtypes.STRING_DTYPE,
36+
"C": bigframes.dtypes.FLOAT_DTYPE,
37+
"D": bigframes.dtypes.BOOL_DTYPE,
38+
}
39+
40+
# Ensure to_pandas_batches returns an iterable
41+
df.to_pandas_batches.return_value = iter(
42+
[pd.DataFrame({"A": [1], "B": ["a"], "C": [1.0], "D": [True]})]
43+
)
44+
45+
# Ensure sort_values returns the mock itself (so to_pandas_batches is still configured)
46+
df.sort_values.return_value = df
47+
yield df
4548

4649

4750
def test_init_raises_if_anywidget_not_installed():

0 commit comments

Comments
 (0)