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/__init__.py +72 -0
- tablelab/__main__.py +64 -0
- tablelab/app.py +431 -0
- tablelab/charts.py +242 -0
- tablelab/code_export.py +545 -0
- tablelab/column_inspector.py +303 -0
- tablelab/figures.py +101 -0
- tablelab/file_readers.py +129 -0
- tablelab/filepicker.py +195 -0
- tablelab/helpers/__init__.py +50 -0
- tablelab/helpers/data.py +30 -0
- tablelab/helpers/plotting.py +31 -0
- tablelab/helpers/widgets.py +5 -0
- tablelab/missing_values.py +97 -0
- tablelab/paged_preview.py +152 -0
- tablelab/pipeline.py +202 -0
- tablelab/query_app.py +1343 -0
- tablelab/query_expressions.py +91 -0
- tablelab/table.py +128 -0
- tablelab-0.5.0.dist-info/METADATA +465 -0
- tablelab-0.5.0.dist-info/RECORD +24 -0
- tablelab-0.5.0.dist-info/WHEEL +4 -0
- tablelab-0.5.0.dist-info/entry_points.txt +2 -0
- tablelab-0.5.0.dist-info/licenses/LICENSE +21 -0
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
|