tablelab 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tablelab/query_app.py ADDED
@@ -0,0 +1,1343 @@
1
+ """A separate notebook GUI for joins, filters, and grouped summaries."""
2
+ import os
3
+ import io
4
+ import json
5
+ import uuid
6
+ from html import escape
7
+
8
+ import ipywidgets as widgets
9
+ import pandas as pd
10
+ from IPython.display import display
11
+
12
+ from . import __version__
13
+ from .file_readers import _has_header, load_table, read_uploaded_dataframe, read_table_source, table_location
14
+ from .filepicker import FilePicker, SaveFilePicker
15
+ from .paged_preview import PagedTablePreview, table_preview_html, join_column_origins, JOIN_COLORS
16
+ from .query_expressions import arithmetic_code, condition_tree
17
+ from .pipeline import OPERATIONS, capture_step, execute_step, step_code, group_summary_name
18
+ from .code_export import export_code
19
+
20
+
21
+ def filter_mask(df, column, operator, value=""):
22
+ """Filter without evaluating code or changing the source table."""
23
+ series = df[column]
24
+ if operator == "missing":
25
+ mask = series.isna()
26
+ elif operator == "present":
27
+ mask = series.notna()
28
+ elif operator == "contains":
29
+ mask = series.astype("string").str.contains(value, regex=False, na=False)
30
+ else:
31
+ if pd.api.types.is_bool_dtype(series.dtype):
32
+ choices = {"true": True, "false": False, "1": True, "0": False}
33
+ if value.strip().lower() not in choices:
34
+ raise ValueError("For Boolean columns, enter true or false.")
35
+ value = choices[value.strip().lower()]
36
+ elif pd.api.types.is_numeric_dtype(series.dtype):
37
+ value = pd.to_numeric(value)
38
+ elif pd.api.types.is_datetime64_any_dtype(series.dtype):
39
+ value = pd.to_datetime(value)
40
+ elif pd.api.types.is_timedelta64_dtype(series.dtype):
41
+ value = pd.to_timedelta(value)
42
+ comparisons = {"eq": series.eq, "ne": series.ne, "gt": series.gt,
43
+ "ge": series.ge, "lt": series.lt, "le": series.le}
44
+ mask = comparisons[operator](value)
45
+ return mask.fillna(False)
46
+
47
+
48
+ def filter_table(df, column, operator, value=""):
49
+ """Filter without evaluating code or changing the source table."""
50
+ return df.loc[filter_mask(df, column, operator, value)].copy()
51
+
52
+
53
+ def group_table(df, keys, aggregate, column=None, alias=''):
54
+ """Keep missing grouping keys and return an ordinary table of summaries."""
55
+ if not keys:
56
+ raise ValueError("Select at least one grouping column.")
57
+ grouped = df.groupby(list(keys), dropna=False, observed=True, sort=False)
58
+ if aggregate == "size":
59
+ name = group_summary_name(keys, aggregate, column, alias)
60
+ return grouped.size().reset_index(name=name)
61
+ if column is None:
62
+ raise ValueError("Select a value column for this summary.")
63
+ if aggregate not in {"count", "sum", "mean", "min", "max", "median"}:
64
+ raise ValueError("Unknown summary operation.")
65
+ if aggregate != "count" and not pd.api.types.is_numeric_dtype(df[column].dtype):
66
+ raise ValueError("This summary requires a numeric value column.")
67
+ name = group_summary_name(keys, aggregate, column, alias)
68
+ return grouped[column].agg(aggregate).reset_index(name=name)
69
+
70
+
71
+ class QueryApp:
72
+ """Sources stay in memory; each query previews a new, independent table."""
73
+
74
+ def __init__(self):
75
+ self.tables = []
76
+ self.pipeline_steps = []
77
+ self._pipelines = {}
78
+ self._pipeline_input = None
79
+ self._pipeline_source_entry = None
80
+ self._pipeline_edit_index = None
81
+ self._pipeline_error_index = None
82
+ self._pipeline_error = ''
83
+ self.result = None
84
+ self._updating = False
85
+ self._updating_rows = False
86
+ self._result_page_size = PagedTablePreview.DEFAULT_PAGE_SIZE
87
+ self.path = widgets.Text(placeholder="local file path or https://… CSV URL",
88
+ layout=widgets.Layout(width="400px"))
89
+ browse = widgets.Button(description="Browse…", icon="folder-open")
90
+ self.load_btn = widgets.Button(description="Add table", icon="plus")
91
+ self.upload = widgets.FileUpload(multiple=False, description="Upload")
92
+ self.picker = FilePicker(self._load_picked)
93
+ self.source = widgets.Dropdown(description="Source table:", disabled=True,
94
+ layout=widgets.Layout(width="450px"),
95
+ style={"description_width": "initial"})
96
+ self.remove_btn = widgets.Button(description="Remove table", icon="trash", disabled=True)
97
+ self.save_path = widgets.Text(
98
+ placeholder="destination path, e.g. exported_table.csv",
99
+ description="CSV path:", continuous_update=False,
100
+ layout=widgets.Layout(width="450px"))
101
+ self.save_btn = widgets.Button(description="Save source as CSV", icon="save", disabled=True,
102
+ layout=widgets.Layout(width="190px"))
103
+ self.save_status = widgets.HTML()
104
+ self.save_picker = SaveFilePicker(self._save_picked)
105
+ self.right = widgets.Dropdown(description="Right table:", disabled=True,
106
+ layout=widgets.Layout(width="450px"))
107
+ self.left_table = widgets.Label(value="[source table]", description="Left table:",
108
+ layout=widgets.Layout(width="300px"))
109
+ self.left_keys = widgets.SelectMultiple(description="Left keys:")
110
+ self.right_keys = widgets.SelectMultiple(description="Right keys:")
111
+ self.left_table.layout.border = self.left_keys.layout.border = '2px solid ' + JOIN_COLORS['left']
112
+ self.right.layout.border = self.right_keys.layout.border = '2px solid ' + JOIN_COLORS['right']
113
+ self.join_type = widgets.Dropdown(description="Join:", options=[
114
+ ("Left — keep all source rows", "left"), ("Inner — matched rows", "inner"),
115
+ ("Right — keep all right rows", "right"), ("Outer — keep both", "outer")])
116
+ self.filter_column = widgets.Dropdown(description="Column:")
117
+ self.operator = widgets.Dropdown(description="Condition 1:",
118
+ style={"description_width": "initial"}, options=[
119
+ ("Equals", "eq"), ("Does not equal", "ne"), ("Greater than", "gt"),
120
+ ("At least", "ge"), ("Less than", "lt"), ("At most", "le"),
121
+ ("Contains text", "contains"), ("Is missing", "missing"),
122
+ ("Is not missing", "present")])
123
+ self.filter_value = widgets.Text(description="Value:", continuous_update=False)
124
+ self.select_columns = widgets.SelectMultiple(description="Columns:")
125
+ self.select_distinct = widgets.Checkbox(description="Distinct rows", indent=False)
126
+ self.select_order = widgets.SelectMultiple(description="Order by:")
127
+ self.select_ascending = widgets.Checkbox(value=True, description="Ascending", indent=False)
128
+ self.select_distinct.layout.width = '140px'
129
+ self.select_ascending.layout.width = '110px'
130
+ self.select_row_count = widgets.HTML(value="<b>Input rows:</b> 0")
131
+ self.select_distinct_count = widgets.HTML(value="<b>Distinct rows:</b> 0")
132
+ self.select_limit = widgets.BoundedIntText(description="Limit:", min=0, max=2**31 - 1, value=0)
133
+ self.select_offset = widgets.BoundedIntText(description="Offset:", min=0, max=2**31 - 1, value=0)
134
+ self.alias_rows = []
135
+ self.calculation_rows = []
136
+ self.condition_rows = []
137
+ self.alias_box = widgets.VBox()
138
+ self.calculation_box = widgets.VBox()
139
+ self.condition_box = widgets.VBox()
140
+ add_alias = widgets.Button(description="Add column alias", icon="plus")
141
+ add_calculation = widgets.Button(description="Add calculated column", icon="plus",
142
+ layout=widgets.Layout(width="210px"))
143
+ add_condition = widgets.Button(description="Add condition", icon="plus")
144
+ self.filter_combine = widgets.Dropdown(description="Combine:", options=[
145
+ ("All (AND)", "and"), ("Any (OR)", "or"), ("Custom grouping", "custom")])
146
+ self.filter_expression = widgets.Text(description="Grouping:", continuous_update=False,
147
+ placeholder="e.g. 1 AND (2 OR 3)", disabled=True,
148
+ layout=widgets.Layout(width="480px"))
149
+ self.missing_columns = widgets.SelectMultiple(description="Columns:")
150
+ self.missing_action = widgets.Dropdown(description="Action:", options=[
151
+ ("Inspect missing counts", "inspect"), ("Remove rows", "drop"),
152
+ ("Fill with a fixed value", "fixed"), ("Fill with median", "median")])
153
+ self.missing_how = widgets.Dropdown(description="Remove if:", disabled=True, options=[
154
+ ("Any selected value is missing", "any"), ("All selected values are missing", "all")],
155
+ layout=widgets.Layout(width="400px"))
156
+ self.missing_value = widgets.Text(description="Fill value:", disabled=True,
157
+ continuous_update=False)
158
+ self.group_keys = widgets.SelectMultiple(description="Group by:")
159
+ self.group_key_order = widgets.Select(description="Key order:", options=[],
160
+ layout=widgets.Layout(width="300px"))
161
+ self.group_key_up = widgets.Button(description="Move up", disabled=True,
162
+ layout=widgets.Layout(width="110px"))
163
+ self.group_key_down = widgets.Button(description="Move down", disabled=True,
164
+ layout=widgets.Layout(width="110px"))
165
+ self.aggregate = widgets.Dropdown(description="Summary:", options=[
166
+ ("Count rows", "size"), ("Count nonmissing values", "count"),
167
+ ("Sum", "sum"), ("Mean", "mean"), ("Median", "median"),
168
+ ("Minimum", "min"), ("Maximum", "max")])
169
+ self.group_alias = widgets.Text(description="Summary alias:", continuous_update=False,
170
+ placeholder="Optional, e.g. total_bills",
171
+ style={"description_width": "initial"},
172
+ layout=widgets.Layout(width="390px"))
173
+ self.tabs = widgets.Tab(children=[
174
+ widgets.VBox([
175
+ widgets.HBox([self.select_columns, self.select_order]),
176
+ widgets.HBox([add_alias, add_calculation]), self.alias_box, self.calculation_box,
177
+ widgets.HTML("Aliases rename output columns. Calculations use +, -, *, /, //, %, "
178
+ "numbers and parentheses, e.g. price * quantity. "
179
+ "Use col('column name') for names with spaces. Calculated columns are included in the output."),
180
+ widgets.HBox([self.select_distinct, self.select_ascending, self.select_row_count,
181
+ self.select_distinct_count],
182
+ layout=widgets.Layout(align_items="center", flex_flow="row wrap", grid_gap="8px")),
183
+ widgets.HBox([self.select_limit, self.select_offset]),
184
+ widgets.HTML("Select columns with Ctrl/Cmd. Order by columns must be selected. "
185
+ "Limit 0 keeps all rows; offset skips rows. "
186
+ "Use Add step to chain filtering or joins before selecting.")]),
187
+ widgets.VBox([widgets.HBox([
188
+ widgets.VBox([self.left_table, self.left_keys]),
189
+ widgets.VBox([self.right, self.right_keys])]),
190
+ self.join_type, widgets.HTML(
191
+ "Select multiple keys with Ctrl/Cmd; matching keys pair in column order. "
192
+ "Duplicate keys may produce several matches; missing keys match each other.")]),
193
+ widgets.VBox([self.filter_column,
194
+ widgets.HBox([self.operator, self.filter_value]),
195
+ self.condition_box, add_condition,
196
+ widgets.HBox([self.filter_combine, self.filter_expression])]),
197
+ widgets.VBox([
198
+ widgets.HBox([self.group_keys, widgets.VBox([
199
+ self.group_key_order, widgets.HBox([self.group_key_up, self.group_key_down])])]),
200
+ widgets.HTML("Select grouping columns, then use Key order and Move up/down to arrange them. "
201
+ "This sets the grouping column sequence in results and generated code. "
202
+ "To sort result rows, add a Select step and choose Order by."),
203
+ self.aggregate, self.filter_column, self.group_alias]),
204
+ widgets.VBox([self.missing_columns,
205
+ widgets.HBox([self.missing_action, self.missing_value],
206
+ layout=widgets.Layout(flex_flow="row wrap")),
207
+ self.missing_how,
208
+ widgets.HTML("Apply to preview missing counts or a cleaned table. "
209
+ "Actions affect only selected columns. Median requires numeric columns "
210
+ "and leaves entirely missing columns unchanged. "
211
+ "Blank strings are not treated as missing. "
212
+ "Use Add step to continue querying the cleaned result.")])])
213
+ for i, title in enumerate(("Select: source table", "Join", "Filter: source table", "Group & summarise: source table", "Missing values: source table")):
214
+ self.tabs.set_title(i, title)
215
+ self._tab_css_class = f"tablelab-query-tabs-{uuid.uuid4().hex}"
216
+ self.tabs.add_class(self._tab_css_class)
217
+ self._tab_tooltip_style = widgets.HTML(layout=widgets.Layout(display="none"))
218
+ self._update_tab_tooltips()
219
+ self.run_btn = widgets.Button(description="Apply and preview result", icon="play", disabled=True,
220
+ button_style="info",
221
+ layout=widgets.Layout(width="230px"))
222
+ self.code_btn = widgets.Button(description="Show code", icon="code", disabled=True,
223
+ layout=widgets.Layout(width="180px"))
224
+ self._displayed_code = None
225
+ self.copy_code_btn = widgets.Button(description="Copy code", icon="copy", disabled=True,
226
+ layout=widgets.Layout(width="140px"))
227
+ self.copy_feedback = widgets.Output()
228
+ self.start_row = widgets.BoundedIntText(
229
+ description="Start row:", min=0, max=0, value=0, disabled=True,
230
+ layout=widgets.Layout(width="150px"), style={"description_width": "70px"})
231
+ self.end_row = widgets.BoundedIntText(
232
+ description="End row:", min=0, max=0, value=0, disabled=True,
233
+ layout=widgets.Layout(width="150px"), style={"description_width": "70px"})
234
+ self.previous = widgets.Button(description="Previous", disabled=True)
235
+ self.next = widgets.Button(description="Next", disabled=True)
236
+ self.row_info = widgets.HTML("Apply a query to browse result rows.")
237
+ self.result_name = widgets.Text(value="Query result", placeholder="Table name",
238
+ tooltip="Name for the result table")
239
+ self.keep_btn = widgets.Button(description="Add as new table, use name:", icon="plus", disabled=True,
240
+ layout=widgets.Layout(width="270px"))
241
+ self.status = widgets.HTML("Add tables using Browse, a path, or Upload.")
242
+ self.source_browser = PagedTablePreview()
243
+ self.source_inspector = None
244
+ self._source_inspection = widgets.VBox()
245
+ self.source_preview = self.source_browser.output
246
+ self.result_preview = widgets.Output()
247
+ first_rows = widgets.Accordion(children=[self.source_browser.ui], selected_index=None)
248
+ self.source_view = first_rows
249
+ first_rows.set_title(0, "View source table")
250
+ first_rows.observe(self._source_view_changed, names="selected_index")
251
+ self.column_view = widgets.Accordion(children=[self._source_inspection], selected_index=None)
252
+ self.column_view.set_title(0, "Inspect a column")
253
+ self.column_view.observe(self._column_view_changed, names="selected_index")
254
+ self.code_target = widgets.Dropdown(
255
+ description='Code output:', options=[('Pandas', 'pandas'), ('PySpark', 'pyspark'),
256
+ ('Spark SQL', 'sql'), ('MySQL 8+', 'mysql'),
257
+ ('PostgreSQL', 'postgres'), ('SQLite 3.39+', 'sqlite'),
258
+ ('DuckDB', 'duckdb')], value='pandas',
259
+ layout=widgets.Layout(width='270px'))
260
+ self.code_help = widgets.HTML()
261
+ self._update_code_help()
262
+ self.pipeline_ui = self._make_pipeline_ui()
263
+ self.ui = widgets.VBox([
264
+ widgets.HTML(f"<b>TableLab {__version__}</b>"),
265
+ widgets.HBox([browse, self.path, self.load_btn, self.upload]), self.picker.ui,
266
+ widgets.HBox([self.source, self.save_btn, self.remove_btn]),
267
+ self.save_picker.ui, self.save_status, first_rows, self.column_view,
268
+ self._tab_tooltip_style, self.tabs, self.pipeline_ui,
269
+ widgets.HBox([self.run_btn, self.code_target, self.code_btn, self.copy_code_btn, self.keep_btn, self.result_name],
270
+ layout=widgets.Layout(flex_flow="row wrap", align_items="center")),
271
+ widgets.HBox([self.start_row, self.end_row, self.previous, self.next],
272
+ layout=widgets.Layout(flex_flow="row wrap")),
273
+ self.code_help, self.copy_feedback,
274
+ self.status, self.row_info,
275
+ self.result_preview])
276
+ browse.on_click(lambda _: self.picker.toggle())
277
+ self.load_btn.on_click(lambda _: self._load_path())
278
+ self.upload.observe(self._load_upload, names="value")
279
+ self.source.observe(lambda _: self._source_changed(), names="value")
280
+ self.right.observe(lambda _: self._right_changed(), names="value")
281
+ self.operator.observe(lambda _: self._filter_controls(), names="value")
282
+ for control in (self.left_keys, self.right_keys, self.join_type, self.filter_column,
283
+ self.operator, self.filter_value, self.group_keys, self.aggregate, self.group_alias,
284
+ self.select_columns, self.select_distinct, self.select_order,
285
+ self.select_ascending, self.select_limit, self.select_offset,
286
+ self.missing_columns, self.missing_action, self.missing_how, self.missing_value):
287
+ control.observe(lambda _: self._invalidate(), names="value")
288
+ self.group_keys.observe(self._sync_group_key_order, names="value")
289
+ self.group_key_order.observe(lambda _: self._group_order_buttons(), names="value")
290
+ self.group_key_up.on_click(lambda _: self._move_group_key(-1))
291
+ self.group_key_down.on_click(lambda _: self._move_group_key(1))
292
+ self.tabs.observe(lambda _: self._invalidate(), names="selected_index")
293
+ self.run_btn.on_click(lambda _: self.run())
294
+ self.code_btn.on_click(lambda _: self.show_code())
295
+ self.copy_code_btn.on_click(lambda _: self.copy_code())
296
+ self.code_target.observe(self._code_target_changed, names="value")
297
+ self.keep_btn.on_click(lambda _: self.keep_result())
298
+ self.remove_btn.on_click(lambda _: self.remove_table())
299
+ self.save_btn.on_click(lambda _: self._open_save_dialog())
300
+ self.previous.on_click(lambda _: self._move_result(-1))
301
+ self.next.on_click(lambda _: self._move_result(1))
302
+ self.start_row.observe(self._on_row_bounds, names="value")
303
+ self.end_row.observe(self._on_row_bounds, names="value")
304
+ add_alias.on_click(lambda _: self.add_alias())
305
+ add_calculation.on_click(lambda _: self.add_calculation())
306
+ add_condition.on_click(lambda _: self.add_condition())
307
+ self.filter_combine.observe(self._combine_changed, names="value")
308
+ self.filter_expression.observe(lambda _: self._invalidate(), names="value")
309
+ self.missing_action.observe(self._missing_controls, names="value")
310
+
311
+ def _make_pipeline_ui(self):
312
+ self.pipeline_info = widgets.HTML('No saved steps. Configure an operation, then Add step.')
313
+ self.pipeline_list = widgets.Select(description='Steps:', options=[],
314
+ layout=widgets.Layout(width='700px', height='120px'))
315
+ buttons = [
316
+ ('pipeline_add_btn', 'Add step', self.add_step),
317
+ ('pipeline_edit_btn', 'Edit step', self.edit_step),
318
+ ('pipeline_save_btn', 'Save changes', self.save_step),
319
+ ('pipeline_cancel_btn', 'Cancel edit', self.cancel_step_edit),
320
+ ('pipeline_up_btn', 'Move up', lambda: self.move_step(-1)),
321
+ ('pipeline_down_btn', 'Move down', lambda: self.move_step(1)),
322
+ ('pipeline_remove_btn', 'Remove step', self.remove_step),
323
+ ('pipeline_preview_btn', 'Preview up to this step', self.preview_step),
324
+ ('pipeline_run_btn', 'Run pipeline', self.run_pipeline),
325
+ ('pipeline_code_btn', 'Show pipeline code', self.show_pipeline_code),
326
+ ('pipeline_clear_btn', 'Clear pipeline', self.clear_pipeline),
327
+ ]
328
+ controls = []
329
+ for attribute, title, callback in buttons:
330
+ button = widgets.Button(description=title, layout=widgets.Layout(width='180px'))
331
+ button.on_click(lambda _, callback=callback: callback())
332
+ setattr(self, attribute, button)
333
+ controls.append(button)
334
+ self.pipeline_run_btn.button_style = 'success'
335
+ self.pipeline_run_btn.icon = 'play'
336
+ self.pipeline_preview_btn.button_style = 'info'
337
+ self.pipeline_preview_btn.icon = 'arrow-right'
338
+ self.pipeline_preview_btn.layout.width = '230px'
339
+ self.pipeline_list.observe(lambda _: self._pipeline_buttons(), names='value')
340
+ ui = widgets.VBox([
341
+ self.pipeline_info, self.pipeline_list,
342
+ widgets.Box(controls, layout=widgets.Layout(flex_flow='row wrap')),
343
+ widgets.HTML('Configure settings above and Add step to chain operations. '
344
+ 'Apply previews the current unsaved settings. Run pipeline previews saved steps. '
345
+ 'Edit a step, then Save changes or Cancel edit.'),
346
+ ])
347
+ panel = widgets.Accordion(children=[ui], selected_index=0)
348
+ panel.set_title(0, 'Query pipeline')
349
+ self._pipeline_buttons()
350
+ return panel
351
+
352
+ def _pipeline_buttons(self):
353
+ index = self.pipeline_list.value
354
+ editing = self._pipeline_edit_index is not None
355
+ for button in (self.pipeline_edit_btn, self.pipeline_remove_btn, self.pipeline_preview_btn):
356
+ button.disabled = index is None or editing
357
+ self.pipeline_add_btn.disabled = self.df is None or editing or self._pipeline_error_index is not None
358
+ self.pipeline_save_btn.disabled = not editing
359
+ self.pipeline_cancel_btn.disabled = not editing
360
+ self.pipeline_up_btn.disabled = editing or index is None or index == 0
361
+ self.pipeline_down_btn.disabled = editing or index is None or index >= len(self.pipeline_steps) - 1
362
+ for button in (self.pipeline_run_btn, self.pipeline_code_btn, self.pipeline_clear_btn):
363
+ button.disabled = not self.pipeline_steps or editing
364
+
365
+ def _pipeline_widgets(self, selected=None):
366
+ options = []
367
+ for index, step in enumerate(self.pipeline_steps):
368
+ label = f"{index + 1}. {OPERATIONS[step['operation']]}"
369
+ if step['operation'] == 1:
370
+ label += f" with {step['right']['name']}"
371
+ if self._pipeline_error_index is not None and index >= self._pipeline_error_index:
372
+ label += ' — needs update'
373
+ options.append((label, index))
374
+ self.pipeline_list.options = options
375
+ self.pipeline_list.value = (selected if selected is not None and selected < len(options)
376
+ else len(options) - 1 if options else None)
377
+ if self._pipeline_edit_index is not None:
378
+ message = f'Editing step {self._pipeline_edit_index + 1}. Save changes to update later steps.'
379
+ elif self._pipeline_error_index is not None:
380
+ message = self._pipeline_error
381
+ elif self.pipeline_steps:
382
+ message = (f'Next step uses step {len(self.pipeline_steps)} result: '
383
+ f'{len(self.df):,} rows × {len(self.df.columns):,} columns.')
384
+ else:
385
+ message = 'No saved steps. Next step uses the selected source table.'
386
+ self.pipeline_info.value = escape(message)
387
+ self._pipeline_buttons()
388
+
389
+ def _sync_pipeline_source(self):
390
+ entry = self.tables[self.source.value] if self.source.value is not None else None
391
+ if entry is self._pipeline_source_entry:
392
+ return
393
+ self._pipeline_source_entry = entry
394
+ self._pipeline_edit_index = None
395
+ self._pipeline_input = None
396
+ self.pipeline_steps = self._pipelines.setdefault(id(entry), []) if entry is not None else []
397
+ self._rebuild_pipeline()
398
+
399
+ def _evaluate_pipeline(self, stop=None):
400
+ if self._pipeline_source_entry is None:
401
+ raise ValueError('Add and select a source table first.')
402
+ frame = self._pipeline_source_entry['df']
403
+ for index, step in enumerate(self.pipeline_steps[:stop]):
404
+ try:
405
+ self._remember_join_origins(frame, step, index + 1)
406
+ frame = execute_step(frame, step)
407
+ except Exception as exc:
408
+ raise ValueError(f"Step {index + 1} ({OPERATIONS[step['operation']]}): {exc}") from exc
409
+ return frame
410
+
411
+ def _rebuild_pipeline(self):
412
+ self._pipeline_error_index = None
413
+ self._pipeline_error = ''
414
+ frame = self._pipeline_source_entry['df'] if self._pipeline_source_entry is not None else None
415
+ for index, step in enumerate(self.pipeline_steps):
416
+ try:
417
+ self._remember_join_origins(frame, step, index + 1)
418
+ frame = execute_step(frame, step)
419
+ except Exception as exc:
420
+ self._pipeline_error_index = index
421
+ self._pipeline_error = f"Step {index + 1} ({OPERATIONS[step['operation']]}): {exc}"
422
+ break
423
+ self._pipeline_input = frame if self.pipeline_steps else None
424
+
425
+ def _pipeline_changed(self, selected=None):
426
+ self._pipeline_edit_index = None
427
+ self._source_changed()
428
+ self._pipeline_widgets(selected)
429
+ if self._pipeline_error_index is not None:
430
+ self.status.value = f"<span style='color:#b3261e'>{escape(self._pipeline_error)}</span>"
431
+
432
+ def add_step(self):
433
+ try:
434
+ if self._pipeline_edit_index is not None:
435
+ raise ValueError('Save or cancel the current edit first.')
436
+ if self._pipeline_error_index is not None:
437
+ raise ValueError('Fix the marked pipeline step before adding another step.')
438
+ if self.df is None:
439
+ raise ValueError('Add and select a source table first.')
440
+ step = capture_step(self)
441
+ result = execute_step(self.df, step)
442
+ self._remember_join_origins(self.df, step, len(self.pipeline_steps) + 1)
443
+ self.pipeline_steps.append(step)
444
+ self._pipeline_input = result
445
+ self._pipeline_changed(len(self.pipeline_steps) - 1)
446
+ if step['operation'] == 1:
447
+ # The saved result is now the next input. Do not leave a second
448
+ # copy of the just-saved Join configured as the current draft.
449
+ self.tabs.selected_index = 0
450
+ self._display_pipeline_result(result, len(self.pipeline_steps))
451
+ if step['operation'] == 1:
452
+ self.status.value += ' Join saved once. Select is ready for the next step.'
453
+ return result
454
+ except Exception as exc:
455
+ self._error(exc)
456
+ return None
457
+
458
+ def _restore_step(self, step):
459
+ def choose(widget, values):
460
+ options = [value[1] if isinstance(value, tuple) else value for value in widget.options]
461
+ if isinstance(values, (list, tuple)):
462
+ widget.value = tuple(value for value in values if value in options)
463
+ else:
464
+ widget.value = values if values in options else None
465
+ operation = step['operation']
466
+ self.tabs.selected_index = operation
467
+ if operation == 0:
468
+ choose(self.select_columns, step['columns'])
469
+ for name, expression in step['calculations']:
470
+ row = self.add_calculation()
471
+ row['name'].value, row['expression'].value = name, expression
472
+ for column, name in step['aliases'].items():
473
+ row = self.add_alias()
474
+ choose(row['column'], column)
475
+ row['name'].value = name
476
+ choose(self.select_order, step['order'])
477
+ for attribute in ('distinct', 'ascending', 'limit', 'offset'):
478
+ getattr(self, 'select_' + attribute).value = step[attribute]
479
+ elif operation == 1:
480
+ right_index = next((index for index, entry in enumerate(self.tables) if entry is step['right']), None)
481
+ if right_index is None:
482
+ self.tables.append(step['right'])
483
+ # Restore the saved right input to the menu without changing the source.
484
+ self._updating = True
485
+ try:
486
+ options = [(f"{index + 1}: {entry['name']}", index) for index, entry in enumerate(self.tables)]
487
+ self.source.options = self.right.options = options
488
+ self.source.value = next(index for index, entry in enumerate(self.tables) if entry is self._pipeline_source_entry)
489
+ finally:
490
+ self._updating = False
491
+ right_index = len(self.tables) - 1
492
+ self.right.value = right_index
493
+ self._right_changed()
494
+ choose(self.left_keys, step['left_keys'])
495
+ choose(self.right_keys, step['right_keys'])
496
+ self.join_type.value = step['how']
497
+ elif operation == 2:
498
+ for index, (column, operator, value) in enumerate(step['conditions']):
499
+ if index == 0:
500
+ choose(self.filter_column, column)
501
+ self.operator.value, self.filter_value.value = operator, value
502
+ else:
503
+ row = self.add_condition()
504
+ choose(row['column'], column)
505
+ row['operator'].value, row['value'].value = operator, value
506
+ self.filter_combine.value, self.filter_expression.value = step['mode'], step['grouping']
507
+ elif operation == 3:
508
+ choose(self.group_keys, step['keys'])
509
+ self.group_key_order.options = tuple(step['keys'])
510
+ self._group_order_buttons()
511
+ choose(self.filter_column, step['column'])
512
+ self.aggregate.value = step['aggregate']
513
+ self.group_alias.value = step.get('alias', '')
514
+ else:
515
+ choose(self.missing_columns, step['columns'])
516
+ self.missing_action.value, self.missing_value.value = step['action'], step['value']
517
+ self.missing_how.value = step['how']
518
+
519
+ def edit_step(self):
520
+ index = self.pipeline_list.value
521
+ if index is None:
522
+ return None
523
+ try:
524
+ frame = self._evaluate_pipeline(index)
525
+ self._pipeline_input = frame
526
+ self._pipeline_edit_index = index
527
+ self._source_changed()
528
+ self._restore_step(self.pipeline_steps[index])
529
+ self._pipeline_widgets(index)
530
+ self.status.value = f'Editing step {index + 1}. Update settings above, then Save changes.'
531
+ return self.pipeline_steps[index]
532
+ except Exception as exc:
533
+ self._error(exc)
534
+ return None
535
+
536
+ def save_step(self):
537
+ index = self._pipeline_edit_index
538
+ if index is None:
539
+ return None
540
+ try:
541
+ step = capture_step(self)
542
+ execute_step(self.df, step)
543
+ self.pipeline_steps[index] = step
544
+ self._rebuild_pipeline()
545
+ self._pipeline_changed(index)
546
+ if self._pipeline_error_index is None:
547
+ self._display_pipeline_result(self.df, len(self.pipeline_steps))
548
+ return step
549
+ except Exception as exc:
550
+ self._error(exc)
551
+ return None
552
+
553
+ def cancel_step_edit(self):
554
+ self._rebuild_pipeline()
555
+ self._pipeline_changed(self._pipeline_edit_index)
556
+
557
+ def move_step(self, direction):
558
+ index = self.pipeline_list.value
559
+ if index is None or self._pipeline_edit_index is not None:
560
+ return
561
+ destination = index + direction
562
+ if not 0 <= destination < len(self.pipeline_steps):
563
+ return
564
+ self.pipeline_steps[index], self.pipeline_steps[destination] = self.pipeline_steps[destination], self.pipeline_steps[index]
565
+ self._rebuild_pipeline()
566
+ self._pipeline_changed(destination)
567
+
568
+ def remove_step(self):
569
+ index = self.pipeline_list.value
570
+ if index is None or self._pipeline_edit_index is not None:
571
+ return
572
+ self.pipeline_steps.pop(index)
573
+ self._rebuild_pipeline()
574
+ self._pipeline_changed(min(index, len(self.pipeline_steps) - 1) if self.pipeline_steps else None)
575
+
576
+ def clear_pipeline(self):
577
+ self.pipeline_steps.clear()
578
+ self._rebuild_pipeline()
579
+ self._pipeline_changed()
580
+
581
+ def _display_pipeline_result(self, result, count):
582
+ self.result = result
583
+ self._result_column_origins = getattr(self, '_pipeline_join_origins', {}).get(count, {})
584
+ self.keep_btn.disabled = False
585
+ self._configure_row_range(len(result))
586
+ self.status.value = (f'Pipeline result through step {count}: {len(result):,} rows × '
587
+ f'{len(result.columns):,} columns. Source tables are unchanged.')
588
+ self._preview_result()
589
+ return result
590
+
591
+ def preview_step(self):
592
+ index = self.pipeline_list.value
593
+ if index is None:
594
+ return None
595
+ self._invalidate()
596
+ try:
597
+ return self._display_pipeline_result(self._evaluate_pipeline(index + 1), index + 1)
598
+ except Exception as exc:
599
+ self._error(exc)
600
+ return None
601
+
602
+ def run_pipeline(self):
603
+ if self._pipeline_edit_index is not None:
604
+ self._error(ValueError('Save or cancel the current edit first.'))
605
+ return None
606
+ self._invalidate()
607
+ self._rebuild_pipeline()
608
+ self._pipeline_widgets(self.pipeline_list.value)
609
+ if self._pipeline_error_index is not None:
610
+ self._error(ValueError(self._pipeline_error))
611
+ return None
612
+ if not self.pipeline_steps:
613
+ self._error(ValueError('Add a pipeline step first.'))
614
+ return None
615
+ return self._display_pipeline_result(self._pipeline_input, len(self.pipeline_steps))
616
+
617
+ def _pipeline_code(self, include_draft=False, target="pandas"):
618
+ if self._pipeline_source_entry is None:
619
+ raise ValueError('Add and select a source table first.')
620
+ steps = list(self.pipeline_steps)
621
+ if include_draft:
622
+ if self._pipeline_edit_index is not None:
623
+ steps = steps[:self._pipeline_edit_index]
624
+ steps.append(capture_step(self))
625
+ if not steps:
626
+ raise ValueError('Add a pipeline step first.')
627
+ if target != 'pandas':
628
+ return export_code(self._pipeline_source_entry, steps, target)
629
+ lines = ['import pandas as pd', ''] + self._entry_read_code(self._pipeline_source_entry, 'source')
630
+ frame = self._pipeline_source_entry['df']
631
+ for index, step in enumerate(steps):
632
+ try:
633
+ lines += ['', f"# Step {index + 1}: {OPERATIONS[step['operation']]}"]
634
+ if step['operation'] == 1:
635
+ lines += self._entry_read_code(step['right'], 'right')
636
+ lines += step_code(frame, step)
637
+ if index < len(steps) - 1:
638
+ frame = execute_step(frame, step)
639
+ lines.append('source = result')
640
+ except Exception as exc:
641
+ raise ValueError(f"Step {index + 1} ({OPERATIONS[step['operation']]}): {exc}") from exc
642
+ return '\n'.join(lines + ['', 'print(result)', '# Optional: save the complete result to a CSV file.',
643
+ "# result.to_csv('query_result.csv', index=False)"]) + '\n'
644
+
645
+ def pipeline_python_code(self):
646
+ return self._pipeline_code()
647
+
648
+ def show_pipeline_code(self):
649
+ try:
650
+ code = self.generated_code(saved=True)
651
+ except Exception as exc:
652
+ self._error(exc)
653
+ return None
654
+ self._invalidate()
655
+ self._display_code(code)
656
+ self.status.value = f'Standalone {self.code_target.label} code for all saved pipeline steps.'
657
+ return code
658
+
659
+ def _remove_row(self, collection, row, box):
660
+ collection.remove(row)
661
+ box.children = [item['ui'] for item in collection]
662
+ self._refresh_extensions()
663
+ for index, condition in enumerate(self.condition_rows, 2):
664
+ condition['operator'].description = f"Condition {index}:"
665
+ self._invalidate()
666
+
667
+ def _refresh_extensions(self):
668
+ columns = list(self.df.columns) if self.df is not None else []
669
+ columns += [row['name'].value.strip() for row in self.calculation_rows
670
+ if row['name'].value.strip() and row['name'].value.strip() not in columns]
671
+ selected = self.select_order.value
672
+ self.select_order.options = columns
673
+ self.select_order.value = tuple(value for value in selected if value in columns)
674
+ for row in self.alias_rows:
675
+ selected = row['column'].value
676
+ row['column'].options = columns
677
+ row['column'].value = selected if selected in columns else (columns[0] if columns else None)
678
+
679
+ def add_alias(self):
680
+ row = {'column': widgets.Dropdown(description='Column:'),
681
+ 'name': widgets.Text(description='Alias:', continuous_update=False)}
682
+ remove = widgets.Button(description='Remove', icon='trash')
683
+ row['ui'] = widgets.HBox([row['column'], row['name'], remove])
684
+ self.alias_rows.append(row)
685
+ self.alias_box.children = [item['ui'] for item in self.alias_rows]
686
+ self._refresh_extensions()
687
+ for field in ('column', 'name'):
688
+ row[field].observe(lambda _: self._invalidate(), names='value')
689
+ remove.on_click(lambda _: self._remove_row(self.alias_rows, row, self.alias_box))
690
+ self._invalidate()
691
+ return row
692
+
693
+ def add_calculation(self):
694
+ row = {'name': widgets.Text(description='New column:', continuous_update=False),
695
+ 'expression': widgets.Text(description='Expression:', continuous_update=False,
696
+ placeholder='price * quantity',
697
+ layout=widgets.Layout(width='420px'))}
698
+ remove = widgets.Button(description='Remove', icon='trash')
699
+ row['ui'] = widgets.HBox([row['name'], row['expression'], remove])
700
+ self.calculation_rows.append(row)
701
+ self.calculation_box.children = [item['ui'] for item in self.calculation_rows]
702
+ def changed(_):
703
+ self._refresh_extensions()
704
+ self._invalidate()
705
+ for field in ('name', 'expression'):
706
+ row[field].observe(changed, names='value')
707
+ remove.on_click(lambda _: self._remove_row(self.calculation_rows, row, self.calculation_box))
708
+ self._invalidate()
709
+ return row
710
+
711
+ def add_condition(self):
712
+ row = {'column': widgets.Dropdown(description='Column:', options=list(self.df.columns) if self.df is not None else []),
713
+ 'operator': widgets.Dropdown(description=f'Condition {len(self.condition_rows) + 2}:',
714
+ style={'description_width': 'initial'}, options=self.operator.options),
715
+ 'value': widgets.Text(description='Value:', continuous_update=False)}
716
+ remove = widgets.Button(description='Remove', icon='trash')
717
+ row['ui'] = widgets.VBox([widgets.HBox([row['column'], remove]),
718
+ widgets.HBox([row['operator'], row['value']])])
719
+ self.condition_rows.append(row)
720
+ self.condition_box.children = [item['ui'] for item in self.condition_rows]
721
+ def changed(_):
722
+ row['value'].disabled = row['operator'].value in {'missing', 'present'}
723
+ self._invalidate()
724
+ for field in ('column', 'operator', 'value'):
725
+ row[field].observe(changed, names='value')
726
+ remove.on_click(lambda _: self._remove_row(self.condition_rows, row, self.condition_box))
727
+ self._invalidate()
728
+ return row
729
+
730
+ def _calculations(self):
731
+ available = list(self.df.columns)
732
+ calculations = []
733
+ for row in self.calculation_rows:
734
+ name, expression = row['name'].value.strip(), row['expression'].value.strip()
735
+ if not name or not expression:
736
+ raise ValueError('Enter a name and expression for every calculated column, or remove its row.')
737
+ if name in available:
738
+ raise ValueError(f'Calculated column already exists: {name!r}')
739
+ code = arithmetic_code(expression, available)
740
+ calculations.append((name, expression, code))
741
+ available.append(name)
742
+ return calculations
743
+
744
+ def _aliases(self, columns):
745
+ aliases = {}
746
+ for row in self.alias_rows:
747
+ column, name = row['column'].value, row['name'].value.strip()
748
+ if not name:
749
+ continue
750
+ if column not in columns:
751
+ raise ValueError('Alias columns must be included in the output.')
752
+ if column in aliases:
753
+ raise ValueError('Use only one alias per column.')
754
+ aliases[column] = name
755
+ names = [aliases.get(column, column) for column in columns]
756
+ if len(set(names)) != len(names):
757
+ raise ValueError('Output column names must be unique; choose different aliases.')
758
+ return aliases
759
+
760
+ def _sync_group_key_order(self, _=None):
761
+ selected = list(self.group_keys.value)
762
+ ordered = [key for key in self.group_key_order.options if key in selected]
763
+ ordered += [key for key in selected if key not in ordered]
764
+ current = self.group_key_order.value
765
+ self.group_key_order.options = ordered
766
+ if current in ordered:
767
+ self.group_key_order.value = current
768
+ self._group_order_buttons()
769
+
770
+ def _group_order_buttons(self):
771
+ keys = list(self.group_key_order.options)
772
+ index = keys.index(self.group_key_order.value) if self.group_key_order.value in keys else -1
773
+ self.group_key_up.disabled = index <= 0
774
+ self.group_key_down.disabled = index < 0 or index >= len(keys) - 1
775
+
776
+ def _move_group_key(self, direction):
777
+ keys = list(self.group_key_order.options)
778
+ current = self.group_key_order.value
779
+ if current not in keys:
780
+ return
781
+ index = keys.index(current)
782
+ destination = index + direction
783
+ if not 0 <= destination < len(keys):
784
+ return
785
+ keys[index], keys[destination] = keys[destination], keys[index]
786
+ self.group_key_order.options = keys
787
+ self.group_key_order.value = current
788
+ self._group_order_buttons()
789
+ self._invalidate()
790
+
791
+ def _combine_changed(self, _=None):
792
+ self.filter_expression.disabled = self.filter_combine.value != "custom"
793
+ self._invalidate()
794
+
795
+ def _conditions(self):
796
+ conditions = [(self.filter_column.value, self.operator.value, self.filter_value.value)]
797
+ conditions += [(row['column'].value, row['operator'].value, row['value'].value)
798
+ for row in self.condition_rows]
799
+ if any(column is None for column, _, _ in conditions):
800
+ raise ValueError('Select a column for every filter condition.')
801
+ mode = self.filter_combine.value
802
+ expression = self.filter_expression.value if mode == "custom" else ""
803
+ if mode == "custom" and not expression.strip():
804
+ raise ValueError("Enter a grouping expression, or choose All (AND) or Any (OR).")
805
+ tree = condition_tree(expression, len(conditions), mode)
806
+ return conditions, tree
807
+
808
+ @property
809
+ def df(self):
810
+ if self.source.value is None:
811
+ return None
812
+ return self._pipeline_input if self._pipeline_input is not None else self.tables[self.source.value]["df"]
813
+
814
+ def add_table(self, df, name="Table", source_path=None, read_options=None):
815
+ """Add an independent source table and select it."""
816
+ self.tables.append({"name": str(name), "df": df.copy(deep=True),
817
+ "source_path": source_path, "read_options": read_options})
818
+ self._fill_table_menus(len(self.tables) - 1,
819
+ self.right.value if self.right.value is not None else 0)
820
+ return self.source.value
821
+
822
+ def _fill_table_menus(self, source, right):
823
+ self._updating = True
824
+ try:
825
+ options = [(f"{i + 1}: {entry['name']}", i) for i, entry in enumerate(self.tables)]
826
+ self.source.options = self.right.options = options
827
+ self.source.value = source
828
+ self.right.value = right
829
+ self.source.disabled = self.right.disabled = self.remove_btn.disabled = not self.tables
830
+ finally:
831
+ self._updating = False
832
+ self._source_changed()
833
+ self._right_changed()
834
+
835
+ def _open_save_dialog(self):
836
+ if self.df is None:
837
+ return
838
+ name = os.path.basename(self.tables[self.source.value]["name"])
839
+ self.save_picker.filename.value = (os.path.splitext(name)[0] or "table") + ".csv"
840
+ self.save_picker.open()
841
+
842
+ def _save_picked(self, path):
843
+ self.save_path.value = path
844
+ return self.save_source_csv()
845
+
846
+ def save_source_csv(self):
847
+ """Save every source row and column, without a generated index column."""
848
+ try:
849
+ if self.df is None:
850
+ raise ValueError("Add and select a source table first.")
851
+ path = self.save_path.value.strip()
852
+ if not path:
853
+ raise ValueError("Enter a destination CSV path first.")
854
+ path = os.path.abspath(os.path.expanduser(path))
855
+ if not path.lower().endswith(".csv"):
856
+ path += ".csv"
857
+ # Exclusive creation avoids replacing an existing source file.
858
+ source_df = self.tables[self.source.value]["df"]
859
+ source_df.to_csv(path, index=False, encoding="utf-8", mode="x")
860
+ self.tables[self.source.value].update(source_path=path, read_options={})
861
+ self.save_status.value = f"Saved {len(source_df):,} rows to {escape(path)}."
862
+ return path
863
+ except FileExistsError:
864
+ self.save_status.value = "That file already exists. Enter a different CSV path."
865
+ except Exception as exc:
866
+ self.save_status.value = f"<span style='color:#b3261e'>{escape(str(exc))}</span>"
867
+ return None
868
+
869
+ def remove_table(self):
870
+ """Remove the selected source and update both selectors and query inputs."""
871
+ selected = self.source.value
872
+ if selected is None:
873
+ return
874
+ previous_right = self.right.value
875
+ self._pipelines.pop(id(self.tables[selected]), None)
876
+ self.tables.pop(selected)
877
+ source = min(selected, len(self.tables) - 1) if self.tables else None
878
+ if previous_right is None or previous_right == selected:
879
+ right = source
880
+ else:
881
+ right = previous_right - (previous_right > selected)
882
+ self._fill_table_menus(source, right)
883
+
884
+ def _load_path(self):
885
+ path = self.path.value.strip()
886
+ if not path:
887
+ self.picker.open()
888
+ self.status.value = 'Choose a file, or paste a local path or direct CSV URL and click Add table.'
889
+ return
890
+ self.load_btn.disabled = True
891
+ self.status.value = 'Loading table…'
892
+ try:
893
+ location, filename, content = read_table_source(path)
894
+ frame = read_uploaded_dataframe(content, filename)
895
+ self.add_table(frame, filename, location, self._csv_options(content, filename))
896
+ except Exception as exc:
897
+ self._error(exc)
898
+ finally:
899
+ self.load_btn.disabled = False
900
+
901
+ def _load_picked(self, path):
902
+ self.path.value = path
903
+ self._load_path()
904
+
905
+ def _load_upload(self, change):
906
+ files = change["new"]
907
+ if not files:
908
+ return
909
+ file = files[0] if isinstance(files, (list, tuple)) else list(files.values())[0]
910
+ try:
911
+ content = bytes(file["content"])
912
+ self.add_table(read_uploaded_dataframe(content, file["name"]), file["name"],
913
+ file["name"], self._csv_options(content, file["name"]))
914
+ except Exception as exc:
915
+ self._error(exc)
916
+
917
+ @staticmethod
918
+ def _csv_options(content, filename):
919
+ """Remember the delimited reader options used by the file loader."""
920
+ ext = os.path.splitext(filename.lower())[1]
921
+ if ext in {".xlsx", ".xls", ".parquet", ".feather", ".arrow",
922
+ ".json", ".jsonl", ".ndjson"}:
923
+ return None
924
+ sep = {".tsv": "\t", ".tab": "\t", ".psv": "|"}.get(ext, ",")
925
+ options = {"sep": sep, "header": 0 if _has_header(content, sep) else None}
926
+ try:
927
+ pd.read_csv(io.BytesIO(content), **options)
928
+ except Exception:
929
+ options.update(sep=None, engine="python")
930
+ return options
931
+
932
+ def _source_view_changed(self, change):
933
+ self.source_browser.set_active(change["new"] == 0)
934
+
935
+ def _column_view_changed(self, change):
936
+ active = change["new"] == 0
937
+ if active and self.source_inspector is None:
938
+ from .column_inspector import ColumnInspector
939
+ self.source_inspector = ColumnInspector()
940
+ self._source_inspection.children = [self.source_inspector.ui]
941
+ name = self.tables[self.source.value]["name"] if self.df is not None else "Source table"
942
+ self.source_inspector.set_table(self._pipeline_source_entry["df"] if self._pipeline_source_entry is not None else None, name)
943
+ if self.source_inspector is not None:
944
+ self.source_inspector.set_active(active)
945
+
946
+ def _source_changed(self):
947
+ if self._updating:
948
+ return
949
+ self._sync_pipeline_source()
950
+ df = self.df
951
+ self.select_row_count.value = f'<b>Input rows:</b> {len(df) if df is not None else 0:,}'
952
+ self.save_btn.disabled = df is None
953
+ self.save_status.value = ""
954
+ self.save_picker.close()
955
+ source_label = (f"{self.source.value + 1}: {self.tables[self.source.value]['name']}"
956
+ if df is not None else None)
957
+ if self.pipeline_steps:
958
+ position = self._pipeline_edit_index if self._pipeline_edit_index is not None else (
959
+ self._pipeline_error_index if self._pipeline_error_index is not None else len(self.pipeline_steps))
960
+ source_label = f"Pipeline step {position} result" if position else source_label
961
+ self.left_table.value = source_label or "[source table]"
962
+ self.tabs.set_title(0, f"Select: {source_label}" if df is not None else "Select: source table")
963
+ self.tabs.set_title(2, f"Filter: {source_label}"
964
+ if df is not None else "Filter: source table")
965
+ self.tabs.set_title(3, f"Group & summarise: {source_label}"
966
+ if df is not None else "Group & summarise: source table")
967
+ self.tabs.set_title(4, f"Missing values: {source_label}" if df is not None else "Missing values: source table")
968
+ self.source.tooltip = source_label
969
+ self._update_tab_tooltips()
970
+ self.alias_rows.clear()
971
+ self.calculation_rows.clear()
972
+ self.condition_rows.clear()
973
+ self.alias_box.children = self.calculation_box.children = self.condition_box.children = ()
974
+ self.group_alias.value = ""
975
+ self.filter_expression.value = ""
976
+ self.filter_combine.value = "and"
977
+ columns = list(df.columns) if df is not None else []
978
+ left_keys = self.left_keys.value
979
+ self.left_keys.options = self.group_keys.options = columns
980
+ self.left_keys.value = tuple(key for key in left_keys if key in columns)
981
+ self.missing_columns.options = columns
982
+ self.missing_columns.value = tuple(columns)
983
+ self.select_columns.options = self.select_order.options = columns
984
+ self.select_columns.value = tuple(columns)
985
+ self.select_order.value = ()
986
+ self.filter_column.options = columns
987
+ self.filter_column.value = columns[0] if columns else None
988
+ self.run_btn.disabled = df is None
989
+ self.code_btn.disabled = df is None
990
+ base = self._pipeline_source_entry['df'] if self._pipeline_source_entry is not None else None
991
+ if self.source_browser.df is not base:
992
+ self.source_browser.set_table(base)
993
+ if self.source_inspector is not None and self.source_inspector.df is not base:
994
+ name = self.tables[self.source.value]["name"] if df is not None else "Source table"
995
+ self.source_inspector.set_table(base, name)
996
+ self._invalidate()
997
+ if df is not None:
998
+ self.status.value = f"Source: {len(df):,} rows × {len(df.columns):,} columns."
999
+ else:
1000
+ self.status.value = "Add tables using Browse, a path, or Upload."
1001
+ self._pipeline_widgets()
1002
+
1003
+ def _right_changed(self):
1004
+ if self._updating:
1005
+ return
1006
+ self.right.tooltip = (f"{self.right.value + 1}: {self.tables[self.right.value]['name']}"
1007
+ if self.right.value is not None else None)
1008
+ right_keys = self.right_keys.value
1009
+ columns = ([] if self.right.value is None else
1010
+ list(self.tables[self.right.value]["df"].columns))
1011
+ self.right_keys.options = columns
1012
+ self.right_keys.value = tuple(key for key in right_keys if key in columns)
1013
+ self._invalidate()
1014
+
1015
+ def _update_tab_tooltips(self):
1016
+ # Some ipywidgets frontends assume a description when a native tooltip
1017
+ # is set. Tabs have no description, so use only the CSS tooltip below.
1018
+ self.tabs.tooltip = None
1019
+ # Lumino tab headers have an empty native title, which suppresses the
1020
+ # parent tooltip. Supply a CSS hover tooltip for that header as well.
1021
+ # Escape every character so filenames cannot inject CSS.
1022
+ selectors = []
1023
+ content_rules = []
1024
+ for index in (0, 2, 3, 4):
1025
+ title = self.tabs.get_title(index)
1026
+ content = "".join(f"\\{ord(char):x} " for char in title)
1027
+ tab_selectors = [
1028
+ f".{self._tab_css_class} .{prefix}-TabBar-tab:nth-child({index + 1}){state}::after"
1029
+ for prefix in ("lm", "p") for state in (":hover", ":focus-visible")]
1030
+ selectors.extend(tab_selectors)
1031
+ content_rules.append(f'{", ".join(tab_selectors)} {{ content: "{content}"; }}')
1032
+ self._tab_tooltip_style.value = f'''<style>
1033
+ {", ".join(selectors)} {{
1034
+ position: fixed;
1035
+ transform: translateY(32px);
1036
+ z-index: 10000;
1037
+ max-width: min(720px, 80vw);
1038
+ padding: 6px 10px;
1039
+ border-radius: 4px;
1040
+ background: #333;
1041
+ color: #fff;
1042
+ font-size: 12px;
1043
+ font-weight: normal;
1044
+ white-space: normal;
1045
+ overflow-wrap: anywhere;
1046
+ pointer-events: none;
1047
+ }}
1048
+ {" ".join(content_rules)}
1049
+ </style>'''
1050
+
1051
+ def _filter_controls(self):
1052
+ self.filter_value.disabled = self.operator.value in {"missing", "present"}
1053
+
1054
+ def _configure_row_range(self, count):
1055
+ self._updating_rows = True
1056
+ try:
1057
+ for control in (self.start_row, self.end_row):
1058
+ control.min = 0
1059
+ control.max = count
1060
+ control.disabled = count == 0
1061
+ # Keep the initial HTML payload small for large query results.
1062
+ start, end = (1, min(count, PagedTablePreview.DEFAULT_PAGE_SIZE)) if count else (0, 0)
1063
+ self.start_row.value, self.end_row.value = start, end
1064
+ self._result_page_size = end - start + 1 if count else PagedTablePreview.DEFAULT_PAGE_SIZE
1065
+ self.previous.disabled = True
1066
+ self.next.disabled = end >= count
1067
+ if count:
1068
+ for control in (self.start_row, self.end_row):
1069
+ control.min = 1
1070
+ finally:
1071
+ self._updating_rows = False
1072
+
1073
+ def _on_row_bounds(self, change):
1074
+ if self._updating_rows or self.result is None:
1075
+ return
1076
+ start, end = self.start_row.value, self.end_row.value
1077
+ if start > end:
1078
+ if change["owner"] is self.start_row:
1079
+ end = start
1080
+ else:
1081
+ start = end
1082
+ self._updating_rows = True
1083
+ try:
1084
+ self.start_row.value, self.end_row.value = start, end
1085
+ finally:
1086
+ self._updating_rows = False
1087
+ self._result_page_size = end - start + 1
1088
+ self._preview_result()
1089
+
1090
+ def _move_result(self, direction):
1091
+ if self.result is None or not len(self.result):
1092
+ return
1093
+ start = (self.end_row.value + 1 if direction > 0 else
1094
+ max(1, self.start_row.value - self._result_page_size))
1095
+ if start > len(self.result):
1096
+ return
1097
+ self._updating_rows = True
1098
+ try:
1099
+ self.start_row.value = start
1100
+ self.end_row.value = min(start + self._result_page_size - 1, len(self.result))
1101
+ finally:
1102
+ self._updating_rows = False
1103
+ self._preview_result()
1104
+
1105
+ def _preview_result(self):
1106
+ if self.result is None:
1107
+ return
1108
+ start, end = self.start_row.value, self.end_row.value
1109
+ self.previous.disabled = not len(self.result) or start <= 1
1110
+ self.next.disabled = not len(self.result) or end >= len(self.result)
1111
+ selected = self.result.iloc[start - 1:end] if len(self.result) else self.result
1112
+ self.row_info.value = (f"Showing rows {start:,}–{end:,} of {len(self.result):,}. "
1113
+ "Row numbers start at 1; both endpoints are included. "
1114
+ "Add result as table keeps the full result." if len(self.result) else
1115
+ "The result has no rows.")
1116
+ column = self.filter_column.value
1117
+ if self.tabs.selected_index == 3:
1118
+ column = self.result.columns[-1]
1119
+ elif column not in self.result.columns and f"{column}_left" in self.result.columns:
1120
+ column = f"{column}_left"
1121
+ origins = getattr(self, '_result_column_origins', {})
1122
+ self._set_result_html(table_preview_html(
1123
+ selected, selected_column=None if origins else column, column_origins=origins).data)
1124
+
1125
+ def _remember_join_origins(self, frame, step, count):
1126
+ if not hasattr(self, '_pipeline_join_origins'):
1127
+ self._pipeline_join_origins = {}
1128
+ self._pipeline_join_origins[count] = self._join_origins(frame, step)
1129
+
1130
+ @staticmethod
1131
+ def _join_origins(frame, step):
1132
+ if step['operation'] != 1:
1133
+ return {}
1134
+ return join_column_origins(frame.columns, step['right']['df'].columns,
1135
+ step['left_keys'], step['right_keys'])
1136
+
1137
+ def _set_result_html(self, html):
1138
+ """Replace one output directly, avoiding notebook output-capture duplicates."""
1139
+ self.result_preview.outputs = ({
1140
+ "output_type": "display_data", "data": {"text/html": html}, "metadata": {},
1141
+ },)
1142
+
1143
+ def _invalidate(self):
1144
+ if self._updating:
1145
+ return
1146
+ self._update_distinct_count()
1147
+ if self.result is not None:
1148
+ self.status.value = "Query settings changed. Preview the result again."
1149
+ self.result = None
1150
+ self.keep_btn.disabled = True
1151
+ self._configure_row_range(0)
1152
+ self.row_info.value = "Apply a query to browse result rows."
1153
+ self.result_preview.outputs = ()
1154
+ self._displayed_code = None
1155
+ self.copy_code_btn.disabled = True
1156
+ self.copy_feedback.outputs = ()
1157
+
1158
+ def _update_distinct_count(self):
1159
+ if not hasattr(self, 'select_distinct_count') or self.tabs.selected_index != 0:
1160
+ return
1161
+ frame = self.df
1162
+ if frame is None:
1163
+ self.select_distinct_count.value = '<b>Distinct rows:</b> 0'
1164
+ self._distinct_count_key = None
1165
+ return
1166
+ try:
1167
+ columns = list(self.select_columns.value)
1168
+ calculations = [(name, expression) for name, expression, _ in self._calculations()]
1169
+ key = (id(frame), tuple(columns), tuple(calculations))
1170
+ if key == getattr(self, '_distinct_count_key', None):
1171
+ return
1172
+ if not columns and not calculations:
1173
+ raise ValueError('Select output columns first.')
1174
+ if calculations:
1175
+ step = dict(operation=0, columns=columns, calculations=calculations, aliases={},
1176
+ order=[], distinct=True, ascending=True, limit=0, offset=0)
1177
+ count = len(execute_step(frame, step))
1178
+ else:
1179
+ count = len(frame[columns].drop_duplicates())
1180
+ self.select_distinct_count.value = f'<b>Distinct rows:</b> {count:,}'
1181
+ self._distinct_count_key = key
1182
+ except Exception:
1183
+ self.select_distinct_count.value = '<b>Distinct rows:</b> — (check selection)'
1184
+ self._distinct_count_key = None
1185
+
1186
+ def _error(self, exc):
1187
+ self._invalidate()
1188
+ self.status.value = f"<span style='color:#b3261e'>{escape(str(exc))}</span>"
1189
+
1190
+ def _table_read_code(self, index, variable):
1191
+ return self._entry_read_code(self.tables[index], variable)
1192
+
1193
+ def _entry_read_code(self, entry, variable):
1194
+ path = entry.get("source_path")
1195
+ notes = []
1196
+ if path is None:
1197
+ path = entry["name"] + ".csv"
1198
+ notes.append(f"# Save the in-memory table as {path!r} before running this code.")
1199
+ reader, options = "read_csv", {}
1200
+ else:
1201
+ ext = os.path.splitext(table_location(path)[1].lower())[1]
1202
+ reader = {".xlsx": "read_excel", ".xls": "read_excel",
1203
+ ".parquet": "read_parquet", ".feather": "read_feather",
1204
+ ".arrow": "read_feather", ".json": "read_json",
1205
+ ".jsonl": "read_json", ".ndjson": "read_json"}.get(ext, "read_csv")
1206
+ options = dict(entry.get("read_options") or {})
1207
+ if ext in {".jsonl", ".ndjson"}:
1208
+ options["lines"] = True
1209
+ if not os.path.isabs(os.fspath(path)) and not os.fspath(path).lower().startswith(("http://", "https://")):
1210
+ notes.append("# Place this file in the working directory, or edit its path below.")
1211
+ arguments = "".join(f", {key}={value!r}" for key, value in options.items())
1212
+ return notes + [f"{variable} = pd.{reader}({os.fspath(path)!r}{arguments})"]
1213
+
1214
+ def _missing_controls(self, _=None):
1215
+ self.missing_how.disabled = self.missing_action.value != "drop"
1216
+ self.missing_value.disabled = self.missing_action.value != "fixed"
1217
+
1218
+ def _select_settings(self):
1219
+ columns = list(self.select_columns.value) + [name for name, _, _ in self._calculations()]
1220
+ order = list(self.select_order.value)
1221
+ if not columns:
1222
+ raise ValueError("Select at least one output column.")
1223
+ if any(column not in columns for column in order):
1224
+ raise ValueError("Order by columns must also be selected as output columns.")
1225
+ return columns, order
1226
+
1227
+ def python_code(self):
1228
+ """Standalone code for saved prefix steps and the current draft settings."""
1229
+ return self._pipeline_code(include_draft=True)
1230
+
1231
+ def _update_code_help(self):
1232
+ guidance = {
1233
+ 'pandas': 'Python code: run in a Python notebook cell or a .py file with pandas installed.',
1234
+ 'pyspark': 'Python code: run in a Spark 3.5+ Python notebook or script with PySpark and Java available.',
1235
+ 'sql': 'SQL code: run in a Spark 3.5+ SQL editor. Do not paste directly into a Python cell. '
1236
+ 'From Python, create a Spark session and pass each statement separately to spark.sql(...).',
1237
+ }
1238
+ for target in ('mysql', 'postgres', 'sqlite', 'duckdb'):
1239
+ guidance[target] = ('SQL code: import the listed source files into the named database tables, '
1240
+ 'then run the query in your database SQL editor. No database connection is made here.')
1241
+ self.code_help.value = (f'<b>{escape(self.code_target.label)}</b> — '
1242
+ f'{escape(guidance[self.code_target.value])} '
1243
+ 'Check input file paths. GUI previews use pandas.')
1244
+
1245
+ def _code_target_changed(self, _):
1246
+ self._update_code_help()
1247
+ # Keep an existing table preview; clear code whose language no longer matches.
1248
+ if self._displayed_code is not None:
1249
+ self._invalidate()
1250
+ self.status.value = 'Code output changed. Click Show code or Show pipeline code.'
1251
+
1252
+ def _display_code(self, code):
1253
+ self._displayed_code = code
1254
+ self.copy_code_btn.disabled = False
1255
+ self._set_result_html(
1256
+ f"<p>{self.code_help.value}</p>"
1257
+ f"<pre style='white-space:pre-wrap;user-select:text'>{escape(code)}</pre>")
1258
+
1259
+ def copy_code(self):
1260
+ """Ask the notebook frontend to copy, with a manual fallback for restricted renderers."""
1261
+ if self._displayed_code is None:
1262
+ return None
1263
+ # JSON encoding keeps source code as data, including quotes and line breaks.
1264
+ encoded = json.dumps(self._displayed_code, ensure_ascii=True)
1265
+ script = """(async () => {
1266
+ const code = CODE;
1267
+ const message = document.createElement('span');
1268
+ element.append(message);
1269
+ try {
1270
+ try {
1271
+ await navigator.clipboard.writeText(code);
1272
+ } catch (_) {
1273
+ const area = document.createElement('textarea');
1274
+ area.value = code;
1275
+ area.style.position = 'fixed';
1276
+ area.style.opacity = '0';
1277
+ document.body.appendChild(area);
1278
+ try {
1279
+ area.focus(); area.select();
1280
+ if (!document.execCommand('copy')) throw new Error('Copy unavailable');
1281
+ } finally { area.remove(); }
1282
+ }
1283
+ message.textContent = 'Code copied.';
1284
+ } catch (_) {
1285
+ message.textContent = 'Clipboard unavailable. Select the code above and copy it manually.';
1286
+ }
1287
+ })();""".replace('CODE', encoded)
1288
+ self.copy_feedback.outputs = ({
1289
+ 'output_type': 'display_data',
1290
+ 'data': {'application/javascript': script,
1291
+ 'text/html': 'If copying is unavailable in this notebook, select the code above and copy it manually.'},
1292
+ 'metadata': {},
1293
+ },)
1294
+ return self._displayed_code
1295
+
1296
+ def generated_code(self, saved=False):
1297
+ """Generate the selected language for saved steps or the current draft."""
1298
+ return self._pipeline_code(include_draft=not saved, target=self.code_target.value)
1299
+
1300
+ def show_code(self):
1301
+ """Show the configured query's code without computing a result."""
1302
+ try:
1303
+ code = self.generated_code()
1304
+ except Exception as exc:
1305
+ self._error(exc)
1306
+ return None
1307
+ self._invalidate()
1308
+ self._display_code(code)
1309
+ self.status.value = f"Standalone {self.code_target.label} code. Check the input file paths before running."
1310
+ self.row_info.value = "Apply the query to preview result rows."
1311
+ return code
1312
+
1313
+ def run(self):
1314
+ """Compute the operation and let the row controls browse its full result."""
1315
+ self._invalidate()
1316
+ try:
1317
+ if self.df is None:
1318
+ raise ValueError("Add and select a source table first.")
1319
+ step = capture_step(self)
1320
+ result = execute_step(self.df, step)
1321
+ self.result = result
1322
+ self._result_column_origins = self._join_origins(self.df, step)
1323
+ self.keep_btn.disabled = False
1324
+ self._configure_row_range(len(result))
1325
+ self.status.value = (f"Result: {len(result):,} rows × {len(result.columns):,} columns. "
1326
+ "Source tables are unchanged.")
1327
+ self._preview_result()
1328
+ return result
1329
+ except Exception as exc:
1330
+ self._error(exc)
1331
+ return None
1332
+
1333
+ def keep_result(self):
1334
+ """Make the previewed result available as a source for further queries."""
1335
+ if self.result is not None:
1336
+ return self.add_table(self.result, self.result_name.value.strip() or "Query result")
1337
+
1338
+
1339
+ def main():
1340
+ """Open the query GUI, independently of the chart inspection notebook."""
1341
+ app = QueryApp()
1342
+ display(app.ui)
1343
+ return app