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/filepicker.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""A browse dialog over the filesystem the kernel can see.
|
|
2
|
+
|
|
3
|
+
`FileUpload` opens the browser's own file dialog, which is the right thing when
|
|
4
|
+
the notebook is on one machine and the file on another - but it copies the whole
|
|
5
|
+
file through the front end, which is slow for a large table and size-capped by
|
|
6
|
+
the browser. For local work the file is already next to the kernel, so browsing
|
|
7
|
+
the kernel's own folders is both faster and unbounded. tablelab offers both and
|
|
8
|
+
lets the situation decide.
|
|
9
|
+
|
|
10
|
+
Built from ordinary widgets rather than a native dialog on purpose: a native one
|
|
11
|
+
opens on the machine running the kernel, which over SSH or in a container is not
|
|
12
|
+
the machine the person is looking at.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import ipywidgets as widgets
|
|
17
|
+
|
|
18
|
+
# What the dialog lists. Everything else is hidden until "all files" is ticked -
|
|
19
|
+
# a data folder is usually full of things that are not tables.
|
|
20
|
+
SUFFIXES = (".csv", ".tsv", ".tab", ".psv", ".txt", ".json", ".jsonl", ".ndjson",
|
|
21
|
+
".parquet", ".feather", ".arrow", ".xlsx", ".xls")
|
|
22
|
+
|
|
23
|
+
PARENT = ".."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FilePicker:
|
|
27
|
+
"""A folder listing. Calls `on_choose(path)` when a file is opened.
|
|
28
|
+
|
|
29
|
+
Clicking a folder walks into it, because a dialog that needs two clicks to
|
|
30
|
+
move one level down is a dialog people stop using.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, on_choose, start=None):
|
|
34
|
+
self._on_choose = on_choose
|
|
35
|
+
self._entries = {} # label shown in the list -> absolute path
|
|
36
|
+
self._folder = os.path.abspath(start or os.getcwd())
|
|
37
|
+
|
|
38
|
+
self.folder = widgets.Text(description="folder:", continuous_update=False,
|
|
39
|
+
layout=widgets.Layout(width="480px"))
|
|
40
|
+
self.up = widgets.Button(description="Up", icon="arrow-up",
|
|
41
|
+
layout=widgets.Layout(width="90px"))
|
|
42
|
+
self.listing = widgets.Select(rows=9, layout=widgets.Layout(width="560px"))
|
|
43
|
+
self.all_files = widgets.Checkbox(value=False, description="all files",
|
|
44
|
+
indent=False,
|
|
45
|
+
layout=widgets.Layout(width="110px"),
|
|
46
|
+
tooltip="List files of every type, not "
|
|
47
|
+
"just the ones tablelab reads")
|
|
48
|
+
self.open_btn = widgets.Button(description="Open", button_style="primary",
|
|
49
|
+
icon="check", disabled=True,
|
|
50
|
+
layout=widgets.Layout(width="110px"))
|
|
51
|
+
self.close_btn = widgets.Button(description="Close",
|
|
52
|
+
layout=widgets.Layout(width="110px"))
|
|
53
|
+
self.note = widgets.HTML()
|
|
54
|
+
|
|
55
|
+
self.up.on_click(lambda _: self._go(os.path.dirname(self._folder)))
|
|
56
|
+
self.folder.observe(self._on_folder, names="value")
|
|
57
|
+
self.all_files.observe(lambda _: self._fill(), names="value")
|
|
58
|
+
self.listing.observe(self._on_select, names="value")
|
|
59
|
+
self.open_btn.on_click(lambda _: self._choose())
|
|
60
|
+
self.close_btn.on_click(lambda _: self.close())
|
|
61
|
+
|
|
62
|
+
self.ui = widgets.VBox(
|
|
63
|
+
[widgets.HBox([self.folder, self.up]),
|
|
64
|
+
self.listing,
|
|
65
|
+
widgets.HBox([self.open_btn, self.close_btn, self.all_files]),
|
|
66
|
+
self.note],
|
|
67
|
+
layout=widgets.Layout(display="none", width="600px", padding="8px",
|
|
68
|
+
border="1px solid #d8d7d3",
|
|
69
|
+
margin="4px 0 8px 0"))
|
|
70
|
+
|
|
71
|
+
# -- open and close ------------------------------------------------------
|
|
72
|
+
def open(self, folder=None):
|
|
73
|
+
"""Show the dialog, starting in `folder` (or where it was left)."""
|
|
74
|
+
if folder:
|
|
75
|
+
self._folder = os.path.abspath(folder)
|
|
76
|
+
self._fill()
|
|
77
|
+
self.ui.layout.display = "flex"
|
|
78
|
+
|
|
79
|
+
def close(self):
|
|
80
|
+
self.ui.layout.display = "none"
|
|
81
|
+
|
|
82
|
+
def toggle(self, folder=None):
|
|
83
|
+
if self.ui.layout.display == "none":
|
|
84
|
+
self.open(folder)
|
|
85
|
+
else:
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
# -- navigation ----------------------------------------------------------
|
|
89
|
+
def _go(self, folder):
|
|
90
|
+
folder = os.path.abspath(os.path.expanduser(folder or self._folder))
|
|
91
|
+
if not os.path.isdir(folder):
|
|
92
|
+
self.note.value = _warn(f"no folder {folder}")
|
|
93
|
+
return
|
|
94
|
+
self._folder = folder
|
|
95
|
+
self._fill()
|
|
96
|
+
|
|
97
|
+
def _on_folder(self, _change):
|
|
98
|
+
self._go(self.folder.value)
|
|
99
|
+
|
|
100
|
+
def _fill(self):
|
|
101
|
+
"""List the current folder: parent first, then folders, then files."""
|
|
102
|
+
_set(self.folder, self._folder, self._on_folder)
|
|
103
|
+
try:
|
|
104
|
+
names = sorted(os.listdir(self._folder), key=str.lower)
|
|
105
|
+
except OSError as e:
|
|
106
|
+
self.note.value = _warn(f"{type(e).__name__}: {e}")
|
|
107
|
+
names = []
|
|
108
|
+
|
|
109
|
+
folders, files = [], []
|
|
110
|
+
for name in names:
|
|
111
|
+
if name.startswith("."): # dotfiles are not what anyone
|
|
112
|
+
continue # came here to open
|
|
113
|
+
path = os.path.join(self._folder, name)
|
|
114
|
+
if os.path.isdir(path):
|
|
115
|
+
folders.append((f"{name}/", path))
|
|
116
|
+
elif self.all_files.value or name.lower().endswith(SUFFIXES):
|
|
117
|
+
files.append((name, path))
|
|
118
|
+
|
|
119
|
+
self._entries = dict([(PARENT, os.path.dirname(self._folder))] + folders + files)
|
|
120
|
+
self.listing.unobserve(self._on_select, names="value")
|
|
121
|
+
self.listing.options = list(self._entries)
|
|
122
|
+
self.listing.value = None # nothing chosen in a new folder
|
|
123
|
+
self.listing.observe(self._on_select, names="value")
|
|
124
|
+
|
|
125
|
+
self.open_btn.disabled = True
|
|
126
|
+
self.note.value = ("" if files else
|
|
127
|
+
"<span style='color:#52514e'>no table files here - "
|
|
128
|
+
"tick <b>all files</b> to see everything</span>")
|
|
129
|
+
|
|
130
|
+
def _on_select(self, _change):
|
|
131
|
+
"""A folder is walked into; a file only arms the Open button."""
|
|
132
|
+
label = self.listing.value
|
|
133
|
+
if label is None:
|
|
134
|
+
return
|
|
135
|
+
path = self._entries[label]
|
|
136
|
+
if label == PARENT or label.endswith("/"):
|
|
137
|
+
self._go(path)
|
|
138
|
+
else:
|
|
139
|
+
self.open_btn.disabled = False
|
|
140
|
+
|
|
141
|
+
def _choose(self):
|
|
142
|
+
label = self.listing.value
|
|
143
|
+
if label is None or label.endswith("/"):
|
|
144
|
+
return
|
|
145
|
+
self.close()
|
|
146
|
+
self._on_choose(self._entries[label])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _set(widget, value, handler):
|
|
150
|
+
"""Set a widget's value without waking its own observer."""
|
|
151
|
+
widget.unobserve(handler, names="value")
|
|
152
|
+
widget.value = value
|
|
153
|
+
widget.observe(handler, names="value")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _warn(text):
|
|
157
|
+
return f"<span style='color:#b3261e'>{text}</span>"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SaveFilePicker(FilePicker):
|
|
161
|
+
"""Browse a destination folder and choose a new CSV filename."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, on_choose, start=None):
|
|
164
|
+
super().__init__(on_choose, start)
|
|
165
|
+
self.filename = widgets.Text(description="File name:", value="table.csv",
|
|
166
|
+
continuous_update=False,
|
|
167
|
+
layout=widgets.Layout(width="480px"))
|
|
168
|
+
self.open_btn.description = "Save"
|
|
169
|
+
self.open_btn.icon = "save"
|
|
170
|
+
self.close_btn.description = "Cancel"
|
|
171
|
+
self.ui.children = (
|
|
172
|
+
widgets.HBox([self.folder, self.up]), self.listing, self.filename,
|
|
173
|
+
widgets.HBox([self.open_btn, self.close_btn]), self.note)
|
|
174
|
+
|
|
175
|
+
def _fill(self):
|
|
176
|
+
super()._fill()
|
|
177
|
+
self.open_btn.disabled = not os.path.isdir(self._folder)
|
|
178
|
+
self.note.value = "Choose a folder and enter a CSV filename."
|
|
179
|
+
|
|
180
|
+
def _on_select(self, change):
|
|
181
|
+
label = self.listing.value
|
|
182
|
+
if label is None:
|
|
183
|
+
return
|
|
184
|
+
if label == PARENT or label.endswith("/"):
|
|
185
|
+
self._go(self._entries[label])
|
|
186
|
+
else:
|
|
187
|
+
self.filename.value = label
|
|
188
|
+
|
|
189
|
+
def _choose(self):
|
|
190
|
+
filename = self.filename.value.strip()
|
|
191
|
+
if not filename or filename in {".", ".."} or os.path.basename(filename) != filename:
|
|
192
|
+
self.note.value = "Enter a filename; choose its folder above."
|
|
193
|
+
return
|
|
194
|
+
if self._on_choose(os.path.join(self._folder, filename)) is not None:
|
|
195
|
+
self.close()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Reusable building blocks behind the notebook GUI.
|
|
2
|
+
|
|
3
|
+
Most users only need ``from tablelab import main``. This namespace keeps the
|
|
4
|
+
data, plotting, and widget helpers discoverable for notebooks that want to
|
|
5
|
+
assemble a more specialised interface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .data import (
|
|
9
|
+
describe,
|
|
10
|
+
find_label_column,
|
|
11
|
+
label_name,
|
|
12
|
+
load_table,
|
|
13
|
+
numeric_columns,
|
|
14
|
+
read_uploaded_dataframe,
|
|
15
|
+
resolve_column,
|
|
16
|
+
row_series,
|
|
17
|
+
summary_stats,
|
|
18
|
+
)
|
|
19
|
+
from .plotting import (
|
|
20
|
+
KINDS,
|
|
21
|
+
apply_grid,
|
|
22
|
+
box_plot,
|
|
23
|
+
draw,
|
|
24
|
+
ecdf,
|
|
25
|
+
histogram,
|
|
26
|
+
run_chart,
|
|
27
|
+
sorted_run_chart,
|
|
28
|
+
)
|
|
29
|
+
from .widgets import FilePicker
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"KINDS",
|
|
33
|
+
"FilePicker",
|
|
34
|
+
"apply_grid",
|
|
35
|
+
"box_plot",
|
|
36
|
+
"describe",
|
|
37
|
+
"draw",
|
|
38
|
+
"ecdf",
|
|
39
|
+
"find_label_column",
|
|
40
|
+
"histogram",
|
|
41
|
+
"label_name",
|
|
42
|
+
"load_table",
|
|
43
|
+
"numeric_columns",
|
|
44
|
+
"read_uploaded_dataframe",
|
|
45
|
+
"resolve_column",
|
|
46
|
+
"row_series",
|
|
47
|
+
"run_chart",
|
|
48
|
+
"sorted_run_chart",
|
|
49
|
+
"summary_stats",
|
|
50
|
+
]
|
tablelab/helpers/data.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Data-loading and table-inspection helpers used by the GUI."""
|
|
2
|
+
|
|
3
|
+
from ..file_readers import load_table, read_uploaded_dataframe
|
|
4
|
+
from ..table import (
|
|
5
|
+
column_choices,
|
|
6
|
+
data_type,
|
|
7
|
+
describe,
|
|
8
|
+
find_label_column,
|
|
9
|
+
is_numeric,
|
|
10
|
+
label_name,
|
|
11
|
+
numeric_columns,
|
|
12
|
+
resolve_column,
|
|
13
|
+
row_series,
|
|
14
|
+
summary_stats,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"column_choices",
|
|
19
|
+
"data_type",
|
|
20
|
+
"describe",
|
|
21
|
+
"find_label_column",
|
|
22
|
+
"is_numeric",
|
|
23
|
+
"label_name",
|
|
24
|
+
"load_table",
|
|
25
|
+
"numeric_columns",
|
|
26
|
+
"read_uploaded_dataframe",
|
|
27
|
+
"resolve_column",
|
|
28
|
+
"row_series",
|
|
29
|
+
"summary_stats",
|
|
30
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Plotting helpers used by the GUI and custom notebooks."""
|
|
2
|
+
|
|
3
|
+
from ..charts import (
|
|
4
|
+
CENTRED,
|
|
5
|
+
KINDS,
|
|
6
|
+
apply_grid,
|
|
7
|
+
box_plot,
|
|
8
|
+
draw,
|
|
9
|
+
ecdf,
|
|
10
|
+
histogram,
|
|
11
|
+
run_chart,
|
|
12
|
+
sorted_run_chart,
|
|
13
|
+
)
|
|
14
|
+
from ..figures import enable_zoom, figure, show, show_in_output, zoom_active
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CENTRED",
|
|
18
|
+
"KINDS",
|
|
19
|
+
"apply_grid",
|
|
20
|
+
"box_plot",
|
|
21
|
+
"draw",
|
|
22
|
+
"ecdf",
|
|
23
|
+
"enable_zoom",
|
|
24
|
+
"figure",
|
|
25
|
+
"histogram",
|
|
26
|
+
"run_chart",
|
|
27
|
+
"show",
|
|
28
|
+
"show_in_output",
|
|
29
|
+
"sorted_run_chart",
|
|
30
|
+
"zoom_active",
|
|
31
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Missing-value operations and their standalone pandas equivalents."""
|
|
2
|
+
import inspect
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _fixed_value(series, value):
|
|
8
|
+
dtype = series.cat.categories.dtype if isinstance(series.dtype, pd.CategoricalDtype) else series.dtype
|
|
9
|
+
if pd.api.types.is_bool_dtype(dtype):
|
|
10
|
+
choices = {'true': True, 'false': False, '1': True, '0': False}
|
|
11
|
+
if value.strip().lower() not in choices:
|
|
12
|
+
raise ValueError(f'{series.name}: enter true or false for Boolean data.')
|
|
13
|
+
return choices[value.strip().lower()]
|
|
14
|
+
if pd.api.types.is_numeric_dtype(dtype):
|
|
15
|
+
return pd.to_numeric(value)
|
|
16
|
+
if pd.api.types.is_datetime64_any_dtype(dtype):
|
|
17
|
+
replacement = pd.to_datetime(value)
|
|
18
|
+
if isinstance(dtype, pd.DatetimeTZDtype) and pd.notna(replacement):
|
|
19
|
+
replacement = (replacement.tz_localize(dtype.tz) if replacement.tzinfo is None
|
|
20
|
+
else replacement.tz_convert(dtype.tz))
|
|
21
|
+
return replacement
|
|
22
|
+
if pd.api.types.is_timedelta64_dtype(dtype):
|
|
23
|
+
return pd.to_timedelta(value)
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fill_column(series, value):
|
|
28
|
+
if not series.isna().any():
|
|
29
|
+
return series.copy()
|
|
30
|
+
replacement = _fixed_value(series, value)
|
|
31
|
+
if pd.isna(replacement):
|
|
32
|
+
raise ValueError(f'{series.name}: the fill value must not itself be missing.')
|
|
33
|
+
if isinstance(series.dtype, pd.CategoricalDtype):
|
|
34
|
+
if replacement not in series.cat.categories:
|
|
35
|
+
series = series.cat.add_categories([replacement])
|
|
36
|
+
elif pd.api.types.is_integer_dtype(series.dtype) and replacement != int(replacement):
|
|
37
|
+
series = series.astype('Float64')
|
|
38
|
+
return series.fillna(replacement)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _median_column(series):
|
|
42
|
+
if not series.isna().any() or not series.notna().any():
|
|
43
|
+
return series.copy()
|
|
44
|
+
median = series.median()
|
|
45
|
+
if pd.api.types.is_integer_dtype(series.dtype) and median != int(median):
|
|
46
|
+
series = series.astype('Float64')
|
|
47
|
+
return series.fillna(median)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_missing(df, columns, action):
|
|
51
|
+
if not columns:
|
|
52
|
+
raise ValueError('Select at least one column for missing-value handling.')
|
|
53
|
+
if action == 'median':
|
|
54
|
+
invalid = [str(column) for column in columns
|
|
55
|
+
if not pd.api.types.is_numeric_dtype(df[column].dtype)
|
|
56
|
+
or pd.api.types.is_bool_dtype(df[column].dtype)
|
|
57
|
+
or pd.api.types.is_complex_dtype(df[column].dtype)]
|
|
58
|
+
if invalid:
|
|
59
|
+
raise ValueError('Median filling requires numeric columns. Deselect: ' + ', '.join(invalid))
|
|
60
|
+
if action not in {'inspect', 'drop', 'fixed', 'median'}:
|
|
61
|
+
raise ValueError('Select a missing-value action.')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def missing_result(df, columns, action, value='', how='any'):
|
|
65
|
+
validate_missing(df, columns, action)
|
|
66
|
+
if action == 'inspect':
|
|
67
|
+
counts = df.loc[:, columns].isna().sum()
|
|
68
|
+
return pd.DataFrame({'column': columns, 'missing_count': counts.to_numpy(),
|
|
69
|
+
'missing_percent': (counts.to_numpy() * 100 / len(df)
|
|
70
|
+
if len(df) else [0.0] * len(columns))})
|
|
71
|
+
if action == 'drop':
|
|
72
|
+
return df.dropna(subset=columns, how=how).copy()
|
|
73
|
+
result = df.copy()
|
|
74
|
+
for column in columns:
|
|
75
|
+
result[column] = (_fill_column(result[column], value) if action == 'fixed'
|
|
76
|
+
else _median_column(result[column]))
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def missing_code(df, columns, action, value='', how='any'):
|
|
81
|
+
validate_missing(df, columns, action)
|
|
82
|
+
if action == 'inspect':
|
|
83
|
+
return [f'columns = {columns!r}', 'counts = source.loc[:, columns].isna().sum()',
|
|
84
|
+
"result = pd.DataFrame({'column': columns, 'missing_count': counts.to_numpy(),",
|
|
85
|
+
" 'missing_percent': (counts.to_numpy() * 100 / len(source)",
|
|
86
|
+
" if len(source) else [0.0] * len(columns))})"]
|
|
87
|
+
if action == 'drop':
|
|
88
|
+
return [f'result = source.dropna(subset={columns!r}, how={how!r}).copy()']
|
|
89
|
+
helper = _fill_column if action == 'fixed' else _median_column
|
|
90
|
+
lines = []
|
|
91
|
+
if action == 'fixed':
|
|
92
|
+
lines.extend(inspect.getsource(_fixed_value).splitlines() + [''])
|
|
93
|
+
lines.extend(inspect.getsource(helper).splitlines() + ['', 'result = source.copy()'])
|
|
94
|
+
lines.append(f'for column in {columns!r}:')
|
|
95
|
+
argument = f', {value!r}' if action == 'fixed' else ''
|
|
96
|
+
lines.append(f' result[column] = {helper.__name__}(result[column]{argument})')
|
|
97
|
+
return lines
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""A bounded preview that references a table and renders only one page."""
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
import ipywidgets as widgets
|
|
5
|
+
from IPython.display import HTML
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
JOIN_COLORS = {'left': '#dceeff', 'right': '#ffe4cc', 'both': '#e7ddf5'}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def join_column_origins(left_columns, right_columns, left_keys, right_keys):
|
|
12
|
+
"""Match pandas merge's suffixes and shared-key projection."""
|
|
13
|
+
common = {left for left, right in zip(left_keys, right_keys) if left == right}
|
|
14
|
+
overlap = (set(left_columns) & set(right_columns)) - common
|
|
15
|
+
origins = {f'{column}_left' if column in overlap else column:
|
|
16
|
+
'both' if column in common else 'left' for column in left_columns}
|
|
17
|
+
origins.update({f'{column}_right' if column in overlap else column: 'right'
|
|
18
|
+
for column in right_columns if column not in common})
|
|
19
|
+
return origins
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def table_preview_html(df, selected_column=None, column_origins=None):
|
|
23
|
+
"""Keep column headers visible inside a scrollable notebook preview."""
|
|
24
|
+
scope = "tablelab-selected-" + uuid.uuid4().hex
|
|
25
|
+
shading = ""
|
|
26
|
+
legend = ''
|
|
27
|
+
if column_origins:
|
|
28
|
+
for position, column in enumerate(df.columns):
|
|
29
|
+
origin = column_origins.get(column)
|
|
30
|
+
if origin in JOIN_COLORS:
|
|
31
|
+
cell = len(df.columns) - position
|
|
32
|
+
shading += (f'.{scope} tr > :nth-last-child({cell}) '
|
|
33
|
+
f'{{ background: {JOIN_COLORS[origin]}; color: #222; }}')
|
|
34
|
+
legend = ('<div>Column origin: ' + ' · '.join(
|
|
35
|
+
f'<span style="background:{JOIN_COLORS[origin]};color:#222;padding:2px 6px">{label}</span>'
|
|
36
|
+
for origin, label in [('left', 'Left table'), ('right', 'Right table'),
|
|
37
|
+
('both', 'Shared join key')]) + '</div>')
|
|
38
|
+
if selected_column is not None:
|
|
39
|
+
for position, column in enumerate(df.columns):
|
|
40
|
+
if column == selected_column:
|
|
41
|
+
# Count from the right so index cells do not affect the position.
|
|
42
|
+
cell = len(df.columns) - position
|
|
43
|
+
shading += f".{scope} tr > :nth-last-child({cell}) {{ background: #fff2cc; color: #222; }}"
|
|
44
|
+
return HTML("<style>" + shading + """
|
|
45
|
+
.tablelab-table-preview { max-height: 420px; overflow: auto; }
|
|
46
|
+
.tablelab-table-preview table { border-collapse: separate; border-spacing: 0; }
|
|
47
|
+
.tablelab-table-preview thead {
|
|
48
|
+
position: sticky;
|
|
49
|
+
top: 0;
|
|
50
|
+
z-index: 1;
|
|
51
|
+
background: var(--jp-layout-color0, white);
|
|
52
|
+
color: var(--jp-ui-font-color1, #222);
|
|
53
|
+
}
|
|
54
|
+
</style>""" + legend + '<div class="tablelab-table-preview ' + scope + '">' + df.to_html() + "</div>")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PagedTablePreview:
|
|
58
|
+
DEFAULT_PAGE_SIZE = 100
|
|
59
|
+
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self.df = None
|
|
62
|
+
self.active = False
|
|
63
|
+
self._updating = False
|
|
64
|
+
self._page_size = self.DEFAULT_PAGE_SIZE
|
|
65
|
+
options = dict(min=0, max=0, value=0, disabled=True,
|
|
66
|
+
layout=widgets.Layout(width="150px"),
|
|
67
|
+
style={"description_width": "70px"})
|
|
68
|
+
self.start_row = widgets.BoundedIntText(description="Start row:", **options)
|
|
69
|
+
self.end_row = widgets.BoundedIntText(description="End row:", **options)
|
|
70
|
+
self.previous = widgets.Button(description="Previous", disabled=True)
|
|
71
|
+
self.next = widgets.Button(description="Next", disabled=True)
|
|
72
|
+
self.info = widgets.HTML()
|
|
73
|
+
self.output = widgets.Output()
|
|
74
|
+
self.ui = widgets.VBox([
|
|
75
|
+
widgets.HBox([self.start_row, self.end_row, self.previous, self.next],
|
|
76
|
+
layout=widgets.Layout(flex_flow="row wrap")), self.info, self.output])
|
|
77
|
+
self.start_row.observe(self._bounds_changed, names="value")
|
|
78
|
+
self.end_row.observe(self._bounds_changed, names="value")
|
|
79
|
+
self.previous.on_click(lambda _: self._move(-1))
|
|
80
|
+
self.next.on_click(lambda _: self._move(1))
|
|
81
|
+
|
|
82
|
+
def set_table(self, df):
|
|
83
|
+
self.df = df # Share the loaded DataFrame; no extra full-table copy.
|
|
84
|
+
count = len(df) if df is not None else 0
|
|
85
|
+
self._updating = True
|
|
86
|
+
try:
|
|
87
|
+
for field in (self.start_row, self.end_row):
|
|
88
|
+
field.min = 0
|
|
89
|
+
field.max = count
|
|
90
|
+
field.disabled = count == 0
|
|
91
|
+
self.start_row.value = 1 if count else 0
|
|
92
|
+
self._page_size = min(count, self.DEFAULT_PAGE_SIZE) if count else self.DEFAULT_PAGE_SIZE
|
|
93
|
+
self.end_row.value = min(count, self._page_size)
|
|
94
|
+
if count:
|
|
95
|
+
self.start_row.min = self.end_row.min = 1
|
|
96
|
+
finally:
|
|
97
|
+
self._updating = False
|
|
98
|
+
self.render()
|
|
99
|
+
|
|
100
|
+
def set_active(self, active):
|
|
101
|
+
self.active = active
|
|
102
|
+
self.render()
|
|
103
|
+
|
|
104
|
+
def _bounds_changed(self, change):
|
|
105
|
+
if self._updating:
|
|
106
|
+
return
|
|
107
|
+
self._updating = True
|
|
108
|
+
try:
|
|
109
|
+
if self.start_row.value > self.end_row.value:
|
|
110
|
+
if change["owner"] is self.start_row:
|
|
111
|
+
self.end_row.value = self.start_row.value
|
|
112
|
+
else:
|
|
113
|
+
self.start_row.value = self.end_row.value
|
|
114
|
+
self._page_size = self.end_row.value - self.start_row.value + 1
|
|
115
|
+
finally:
|
|
116
|
+
self._updating = False
|
|
117
|
+
self.render()
|
|
118
|
+
|
|
119
|
+
def _move(self, direction):
|
|
120
|
+
if self.df is None or not len(self.df):
|
|
121
|
+
return
|
|
122
|
+
candidate = (self.end_row.value + 1 if direction > 0 else
|
|
123
|
+
max(1, self.start_row.value - self._page_size))
|
|
124
|
+
if candidate > len(self.df):
|
|
125
|
+
return
|
|
126
|
+
self._updating = True
|
|
127
|
+
try:
|
|
128
|
+
self.start_row.value = candidate
|
|
129
|
+
self.end_row.value = min(candidate + self._page_size - 1, len(self.df))
|
|
130
|
+
finally:
|
|
131
|
+
self._updating = False
|
|
132
|
+
self.render()
|
|
133
|
+
|
|
134
|
+
def render(self):
|
|
135
|
+
count = len(self.df) if self.df is not None else 0
|
|
136
|
+
start, end = self.start_row.value, self.end_row.value
|
|
137
|
+
self.previous.disabled = not count or start <= 1
|
|
138
|
+
self.next.disabled = not count or end >= count
|
|
139
|
+
self.info.value = (f"Rows {start:,}–{end:,} of {count:,}. "
|
|
140
|
+
f"Page size: {self._page_size:,} rows (End − Start + 1)."
|
|
141
|
+
if count else "No source rows to display.")
|
|
142
|
+
if self.active and count:
|
|
143
|
+
# Replace the widget's state directly. Output capture and deferred
|
|
144
|
+
# clear_output messages can accumulate displays in notebook clients.
|
|
145
|
+
preview = table_preview_html(self.df.iloc[start - 1:end])
|
|
146
|
+
self.output.outputs = ({
|
|
147
|
+
"output_type": "display_data",
|
|
148
|
+
"data": {"text/html": preview.data},
|
|
149
|
+
"metadata": {},
|
|
150
|
+
},)
|
|
151
|
+
else:
|
|
152
|
+
self.output.outputs = ()
|