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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Column inspection and paired numeric plots, without file-loading controls."""
|
|
2
|
+
from html import escape
|
|
3
|
+
|
|
4
|
+
import ipywidgets as widgets
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .app import TableApp
|
|
10
|
+
from .helpers import plotting as figures
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ColumnInspector(TableApp):
|
|
14
|
+
"""Inspect the shared source DataFrame without changing query settings."""
|
|
15
|
+
|
|
16
|
+
MAX_SCATTER_POINTS = 5000
|
|
17
|
+
|
|
18
|
+
def __init__(self, zoom=True):
|
|
19
|
+
self.active = False
|
|
20
|
+
self._setting_pair = False
|
|
21
|
+
self._relationship_fig = None
|
|
22
|
+
self._setting_range = False
|
|
23
|
+
super().__init__(zoom=zoom)
|
|
24
|
+
self.compare = widgets.Checkbox(description='Compare columns', value=False,
|
|
25
|
+
indent=False, layout=widgets.Layout(width='160px'))
|
|
26
|
+
self.relationship_x = widgets.Dropdown(description='Against (X):', options=[], disabled=True,
|
|
27
|
+
layout=widgets.Layout(width='320px', display='none'))
|
|
28
|
+
self.comparison_view = widgets.Dropdown(
|
|
29
|
+
description='View:', options=[('Scatter (Y vs X)', 'scatter'),
|
|
30
|
+
('Two curves vs samples', 'samples')],
|
|
31
|
+
layout=widgets.Layout(width='290px', display='none'))
|
|
32
|
+
# The existing column selector is also the scatter plot's Y selector.
|
|
33
|
+
self.relationship_y = self.column
|
|
34
|
+
self.relationship_info = widgets.HTML()
|
|
35
|
+
self.relationship_chart = self.chart
|
|
36
|
+
self.encode_categories = widgets.Checkbox(description='Encode categories', indent=False,
|
|
37
|
+
layout=widgets.Layout(width='180px'))
|
|
38
|
+
self.encoding_mapping = widgets.HTML()
|
|
39
|
+
self.encode_categories.observe(self._encoding_changed, names='value')
|
|
40
|
+
self._column_box.children = [self.column, self.data_type, self.encode_categories,
|
|
41
|
+
self.compare, self.relationship_x, self.comparison_view]
|
|
42
|
+
self._column_box.layout.flex_flow = 'row wrap'
|
|
43
|
+
self._chart_area = widgets.VBox([
|
|
44
|
+
self._view_box, self.chart_tabs, self.text_preview, self.stats_acc])
|
|
45
|
+
self.compare.observe(lambda _: self._draw(), names='value')
|
|
46
|
+
self.relationship_x.observe(lambda _: self._draw_relationship(), names='value')
|
|
47
|
+
self.comparison_view.observe(lambda _: self._draw_relationship(), names='value')
|
|
48
|
+
self.start_row = widgets.BoundedIntText(description='Start row:', value=1, min=1, max=1)
|
|
49
|
+
self.end_row = widgets.BoundedIntText(description='End row:', value=1, min=1, max=1)
|
|
50
|
+
self.show_all_btn = widgets.Button(description='Show all')
|
|
51
|
+
self.range_info = widgets.HTML()
|
|
52
|
+
self.start_row.observe(self._range_changed, names='value')
|
|
53
|
+
self.end_row.observe(self._range_changed, names='value')
|
|
54
|
+
self.show_all_btn.on_click(self._show_all)
|
|
55
|
+
self.ui = widgets.VBox([self.status,
|
|
56
|
+
widgets.HBox([self.start_row, self.end_row, self.show_all_btn]),
|
|
57
|
+
self.range_info, self._column_box, self.encoding_mapping, self._chart_area])
|
|
58
|
+
|
|
59
|
+
def _encoding_changed(self, _):
|
|
60
|
+
self._type_selection = None
|
|
61
|
+
self._draw()
|
|
62
|
+
|
|
63
|
+
def _encoded_column(self, column=None):
|
|
64
|
+
"""Use a stable full-source mapping so row-range changes retain codes."""
|
|
65
|
+
column = self.column.value if column is None else column
|
|
66
|
+
values = self.df[column]
|
|
67
|
+
codes, categories = pd.factorize(values, sort=False)
|
|
68
|
+
encoded = pd.Series(codes, index=values.index, name=values.name, dtype=float)
|
|
69
|
+
encoded = encoded.mask(encoded < 0)
|
|
70
|
+
mapping = pd.DataFrame({'Category': list(categories), 'Code': range(len(categories))})
|
|
71
|
+
if not hasattr(self, '_encoding_mappings'):
|
|
72
|
+
self._encoding_mappings = {}
|
|
73
|
+
self._encoding_mappings[column] = mapping
|
|
74
|
+
self.encoding_mapping.value = (
|
|
75
|
+
'<b>Category mapping</b> (first appearance in the full source). '
|
|
76
|
+
'Missing values remain missing. Codes are labels, not quantities or ranks. '
|
|
77
|
+
'Encoding applies only to inspection; source data is unchanged.'
|
|
78
|
+
'<div style="max-height:220px;overflow:auto">' + ''.join(
|
|
79
|
+
'<b>' + escape(str(name)) + '</b>' + table.to_html(index=False, escape=True)
|
|
80
|
+
for name, table in self._encoding_mappings.items()) + '</div>')
|
|
81
|
+
return encoded
|
|
82
|
+
|
|
83
|
+
def _comparison_frame(self, x, y):
|
|
84
|
+
self._encoding_mappings = {}
|
|
85
|
+
self.encoding_mapping.value = ''
|
|
86
|
+
frame = self.inspection_frame()[[x, y]].copy(deep=False)
|
|
87
|
+
if self.encode_categories.value:
|
|
88
|
+
for column in (x, y):
|
|
89
|
+
if (not pd.api.types.is_numeric_dtype(frame[column].dtype)
|
|
90
|
+
or pd.api.types.is_bool_dtype(frame[column].dtype)):
|
|
91
|
+
frame[column] = self._encoded_column(column).iloc[
|
|
92
|
+
self.start_row.value - 1:self.end_row.value].to_numpy()
|
|
93
|
+
return frame
|
|
94
|
+
|
|
95
|
+
def set_table(self, df, name="Source table"):
|
|
96
|
+
self._setting_range = True
|
|
97
|
+
try:
|
|
98
|
+
count = len(df) if df is not None else 0
|
|
99
|
+
self.start_row.max = self.end_row.max = max(1, count)
|
|
100
|
+
self.start_row.value = 1
|
|
101
|
+
self.end_row.value = max(1, min(100, count))
|
|
102
|
+
self.start_row.disabled = self.end_row.disabled = self.show_all_btn.disabled = not count
|
|
103
|
+
finally:
|
|
104
|
+
self._setting_range = False
|
|
105
|
+
# Reference the query app's table rather than copying the full dataset.
|
|
106
|
+
self.tables = ([] if df is None else
|
|
107
|
+
[{"name": name, "source": None, "df": df}])
|
|
108
|
+
self._fill_table_menu(0 if df is not None else None)
|
|
109
|
+
self._update_range_info()
|
|
110
|
+
|
|
111
|
+
def _update_range_info(self):
|
|
112
|
+
count = 0 if self.df is None else len(self.df)
|
|
113
|
+
shown = len(self.inspection_frame()) if count else 0
|
|
114
|
+
self.range_info.value = (f'Inspecting {shown:,} of {count:,} rows. '
|
|
115
|
+
'Range uses source row positions, starting at 1; source data stays loaded.')
|
|
116
|
+
|
|
117
|
+
def inspection_frame(self):
|
|
118
|
+
if self.df is None:
|
|
119
|
+
return None
|
|
120
|
+
return self.df.iloc[self.start_row.value - 1:self.end_row.value]
|
|
121
|
+
|
|
122
|
+
def series(self):
|
|
123
|
+
chosen = super().series()
|
|
124
|
+
if chosen is None or not hasattr(self, 'start_row'):
|
|
125
|
+
return chosen
|
|
126
|
+
values, title, xlabel, ylabel = chosen
|
|
127
|
+
if hasattr(self, 'encode_categories') and self.encode_categories.value:
|
|
128
|
+
values = self._encoded_column()
|
|
129
|
+
title += ' (category codes)'
|
|
130
|
+
ylabel = 'Category code'
|
|
131
|
+
return values.iloc[self.start_row.value - 1:self.end_row.value], title, xlabel, ylabel
|
|
132
|
+
|
|
133
|
+
def _range_changed(self, change):
|
|
134
|
+
if self._setting_range:
|
|
135
|
+
return
|
|
136
|
+
self._setting_range = True
|
|
137
|
+
try:
|
|
138
|
+
if self.start_row.value > self.end_row.value:
|
|
139
|
+
if change['owner'] is self.start_row:
|
|
140
|
+
self.end_row.value = self.start_row.value
|
|
141
|
+
else:
|
|
142
|
+
self.start_row.value = self.end_row.value
|
|
143
|
+
finally:
|
|
144
|
+
self._setting_range = False
|
|
145
|
+
self._update_range_info()
|
|
146
|
+
self._draw()
|
|
147
|
+
|
|
148
|
+
def _show_all(self, _=None):
|
|
149
|
+
self._setting_range = True
|
|
150
|
+
try:
|
|
151
|
+
self.start_row.value = 1
|
|
152
|
+
self.end_row.value = max(1, len(self.df) if self.df is not None else 0)
|
|
153
|
+
finally:
|
|
154
|
+
self._setting_range = False
|
|
155
|
+
self._update_range_info()
|
|
156
|
+
self._draw()
|
|
157
|
+
|
|
158
|
+
def _select_table(self):
|
|
159
|
+
if self._loading:
|
|
160
|
+
return
|
|
161
|
+
super()._select_table()
|
|
162
|
+
if hasattr(self, 'compare'):
|
|
163
|
+
self._refresh_relationship()
|
|
164
|
+
if self.df is None:
|
|
165
|
+
self._clear_relationship()
|
|
166
|
+
|
|
167
|
+
def _refresh_relationship(self):
|
|
168
|
+
columns = ([] if self.df is None else [column for column in self.df.columns
|
|
169
|
+
if pd.api.types.is_numeric_dtype(self.df[column].dtype)
|
|
170
|
+
and not pd.api.types.is_bool_dtype(self.df[column].dtype)
|
|
171
|
+
and not pd.api.types.is_complex_dtype(self.df[column].dtype)])
|
|
172
|
+
if self.df is not None and self.encode_categories.value:
|
|
173
|
+
columns = [column for column in self.df.columns
|
|
174
|
+
if not pd.api.types.is_complex_dtype(self.df[column].dtype)]
|
|
175
|
+
candidates = [column for column in columns if column != self.column.value]
|
|
176
|
+
old_x = self.relationship_x.value
|
|
177
|
+
self._setting_pair = True
|
|
178
|
+
try:
|
|
179
|
+
self.relationship_x.options = candidates
|
|
180
|
+
self.relationship_x.value = old_x if old_x in candidates else (candidates[0] if candidates else None)
|
|
181
|
+
self.relationship_x.disabled = self.column.value not in columns or not candidates
|
|
182
|
+
finally:
|
|
183
|
+
self._setting_pair = False
|
|
184
|
+
|
|
185
|
+
def _clear_relationship(self):
|
|
186
|
+
if self._relationship_fig is not None:
|
|
187
|
+
plt.close(self._relationship_fig)
|
|
188
|
+
self._relationship_fig = None
|
|
189
|
+
self.relationship_chart.outputs = ()
|
|
190
|
+
self.relationship_info.value = ''
|
|
191
|
+
|
|
192
|
+
def _draw_relationship(self):
|
|
193
|
+
if self._setting_pair:
|
|
194
|
+
return
|
|
195
|
+
if not self.compare.value:
|
|
196
|
+
return
|
|
197
|
+
self._clear_relationship()
|
|
198
|
+
if not self.active or self._loading:
|
|
199
|
+
return
|
|
200
|
+
if self.df is None or self.relationship_x.disabled:
|
|
201
|
+
self.relationship_info.value = 'Choose a numeric column with another numeric column available.'
|
|
202
|
+
return
|
|
203
|
+
x, y = self.relationship_x.value, self.relationship_y.value
|
|
204
|
+
if x == y:
|
|
205
|
+
self.relationship_info.value = 'Choose two different columns to compare.'
|
|
206
|
+
return
|
|
207
|
+
if self.comparison_view.value == 'samples':
|
|
208
|
+
self._draw_sample_curves(x, y)
|
|
209
|
+
return
|
|
210
|
+
# Keep only valid pairs; do not copy the source or align by possibly duplicate index labels.
|
|
211
|
+
frame = self._comparison_frame(x, y)
|
|
212
|
+
valid = (np.isfinite(frame[x]) & np.isfinite(frame[y])).fillna(False)
|
|
213
|
+
positions = np.flatnonzero(valid.to_numpy(dtype=bool))
|
|
214
|
+
count = len(positions)
|
|
215
|
+
omitted = len(frame) - count
|
|
216
|
+
if not count:
|
|
217
|
+
self.relationship_info.value = 'No rows have finite numeric values in both columns.'
|
|
218
|
+
return
|
|
219
|
+
if count > self.MAX_SCATTER_POINTS:
|
|
220
|
+
positions = positions[np.linspace(0, count - 1, self.MAX_SCATTER_POINTS, dtype=int)]
|
|
221
|
+
xv = frame[x].iloc[positions].to_numpy(dtype=float)
|
|
222
|
+
yv = frame[y].iloc[positions].to_numpy(dtype=float)
|
|
223
|
+
fig = figures.figure()
|
|
224
|
+
self._relationship_fig = fig
|
|
225
|
+
ax = fig.add_subplot(111)
|
|
226
|
+
ax.scatter(xv, yv, alpha=0.55, s=18)
|
|
227
|
+
ax.set(xlabel=str(x), ylabel=str(y), title=f'{y} vs {x}')
|
|
228
|
+
ax.grid(True, alpha=0.25)
|
|
229
|
+
self.relationship_info.value = (
|
|
230
|
+
f'{escape(str(y))} vs {escape(str(x))}: showing {len(positions):,} of {count:,} valid pairs. '
|
|
231
|
+
f'Skipped {omitted:,} rows with missing or infinite values.'
|
|
232
|
+
+ (' Points are sampled evenly by row position.' if count > len(positions) else ''))
|
|
233
|
+
figures.show_in_output(fig, self.relationship_chart)
|
|
234
|
+
|
|
235
|
+
def _draw_sample_curves(self, x, y):
|
|
236
|
+
frame = self._comparison_frame(x, y)
|
|
237
|
+
count = len(frame)
|
|
238
|
+
if not count:
|
|
239
|
+
self.relationship_info.value = 'No samples to plot.'
|
|
240
|
+
return
|
|
241
|
+
positions = (np.linspace(0, count - 1, self.MAX_SCATTER_POINTS, dtype=int)
|
|
242
|
+
if count > self.MAX_SCATTER_POINTS else np.arange(count))
|
|
243
|
+
xv = frame[x].iloc[positions].to_numpy(dtype=float, na_value=np.nan, copy=True)
|
|
244
|
+
yv = frame[y].iloc[positions].to_numpy(dtype=float, na_value=np.nan, copy=True)
|
|
245
|
+
xv[~np.isfinite(xv)] = np.nan
|
|
246
|
+
yv[~np.isfinite(yv)] = np.nan
|
|
247
|
+
if not np.isfinite(xv).any() and not np.isfinite(yv).any():
|
|
248
|
+
self.relationship_info.value = 'No finite values in the selected samples.'
|
|
249
|
+
return
|
|
250
|
+
fig = figures.figure()
|
|
251
|
+
self._relationship_fig = fig
|
|
252
|
+
ax = fig.add_subplot(111)
|
|
253
|
+
samples = positions + self.start_row.value
|
|
254
|
+
ax.plot(samples, xv, label=str(x), linewidth=1, marker='.', markersize=3)
|
|
255
|
+
ax.plot(samples, yv, label=str(y), linewidth=1, marker='.', markersize=3)
|
|
256
|
+
ax.set(xlabel='Sample (row position, starting at 1)', ylabel='Value',
|
|
257
|
+
title=f'{x} and {y} vs samples')
|
|
258
|
+
ax.legend()
|
|
259
|
+
ax.grid(True, alpha=0.25)
|
|
260
|
+
self.relationship_info.value = (
|
|
261
|
+
f'Showing {len(positions):,} of {count:,} sample positions in source row order. '
|
|
262
|
+
'Missing or infinite values appear as gaps in each curve.'
|
|
263
|
+
+ (' Positions are sampled evenly.' if count > len(positions) else ''))
|
|
264
|
+
figures.show_in_output(fig, self.relationship_chart)
|
|
265
|
+
|
|
266
|
+
def set_active(self, active):
|
|
267
|
+
self.active = active
|
|
268
|
+
if active:
|
|
269
|
+
self._draw()
|
|
270
|
+
elif self.compare.value:
|
|
271
|
+
self._clear_relationship()
|
|
272
|
+
|
|
273
|
+
def _draw(self):
|
|
274
|
+
if not hasattr(self, 'compare'):
|
|
275
|
+
if self.active:
|
|
276
|
+
super()._draw()
|
|
277
|
+
return
|
|
278
|
+
if self._loading:
|
|
279
|
+
return
|
|
280
|
+
if hasattr(self, 'encoding_mapping'):
|
|
281
|
+
self.encoding_mapping.value = ''
|
|
282
|
+
self._encoding_mappings = {}
|
|
283
|
+
self.encode_categories.disabled = self.df is None
|
|
284
|
+
self._refresh_relationship()
|
|
285
|
+
comparing = self.compare.value
|
|
286
|
+
self.relationship_x.layout.display = '' if comparing else 'none'
|
|
287
|
+
self.comparison_view.layout.display = '' if comparing else 'none'
|
|
288
|
+
self.data_type.layout.display = 'none' if comparing else ''
|
|
289
|
+
self.column.description = 'Column (Y):' if comparing else 'column:'
|
|
290
|
+
if comparing:
|
|
291
|
+
self._chart_area.children = [self.relationship_info, self.chart]
|
|
292
|
+
self.stats.outputs = self.text_preview.outputs = ()
|
|
293
|
+
if self._fig is not None:
|
|
294
|
+
plt.close(self._fig)
|
|
295
|
+
self._fig = None
|
|
296
|
+
self._draw_relationship()
|
|
297
|
+
else:
|
|
298
|
+
# Clear the shared chart only when leaving comparison mode.
|
|
299
|
+
if self._relationship_fig is not None or self.relationship_info.value:
|
|
300
|
+
self._clear_relationship()
|
|
301
|
+
self._chart_area.children = [self._view_box, self.chart_tabs, self.text_preview, self.stats_acc]
|
|
302
|
+
if self.active:
|
|
303
|
+
super()._draw()
|
tablelab/figures.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Where the chart is put on screen, and whether it can be zoomed.
|
|
2
|
+
|
|
3
|
+
The pan/zoom toolbar belongs to ipympl, not to matplotlib: under the default
|
|
4
|
+
inline backend a figure arrives as a finished PNG, so there is nothing left to
|
|
5
|
+
zoom into. Switching the backend is therefore the whole of "make the chart
|
|
6
|
+
interactive", and it is a notebook-wide switch - hence one place for it here,
|
|
7
|
+
called once by the app rather than scattered through the drawing code.
|
|
8
|
+
|
|
9
|
+
Without ipympl everything still works; the chart is simply a static image.
|
|
10
|
+
"""
|
|
11
|
+
import importlib.util
|
|
12
|
+
import base64
|
|
13
|
+
import io
|
|
14
|
+
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
from IPython.display import display
|
|
17
|
+
|
|
18
|
+
# Looked up rather than imported: importing ipympl switches the backend as a side
|
|
19
|
+
# effect, which would take the choice away from `zoom=False` before it was asked.
|
|
20
|
+
HAVE_IPYMPL = importlib.util.find_spec("ipympl") is not None
|
|
21
|
+
|
|
22
|
+
# One figure, reused: a new one per redraw would leave every previous chart open
|
|
23
|
+
# behind the scenes, and matplotlib warns about that at twenty.
|
|
24
|
+
FIGURE = "tablelab"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enable_zoom() -> bool:
|
|
28
|
+
"""Activate the ipympl backend. Returns whether zoom is actually available.
|
|
29
|
+
|
|
30
|
+
Safe to call more than once.
|
|
31
|
+
"""
|
|
32
|
+
if not HAVE_IPYMPL:
|
|
33
|
+
return False
|
|
34
|
+
shell = _shell()
|
|
35
|
+
if shell is not None:
|
|
36
|
+
shell.run_line_magic("matplotlib", "widget")
|
|
37
|
+
else: # plain interpreter, e.g. a test
|
|
38
|
+
plt.switch_backend("module://ipympl.backend_nbagg")
|
|
39
|
+
return zoom_active()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def zoom_active() -> bool:
|
|
43
|
+
"""True when figures are ipympl canvases rather than images.
|
|
44
|
+
|
|
45
|
+
`%matplotlib widget` reports the backend as "widget" while `switch_backend`
|
|
46
|
+
reports "module://ipympl.backend_nbagg" - accept either spelling.
|
|
47
|
+
"""
|
|
48
|
+
return any(k in plt.get_backend().lower() for k in ("ipympl", "widget", "nbagg"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def figure(figsize=(10, 4)):
|
|
52
|
+
"""The app's one figure, cleared and ready to draw into.
|
|
53
|
+
|
|
54
|
+
Built with interactive mode off: an ipympl figure made while a cell is still
|
|
55
|
+
running shows itself when the cell ends, which would put a second copy of the
|
|
56
|
+
chart below the app. The app displays it where it wants it instead.
|
|
57
|
+
"""
|
|
58
|
+
with plt.ioff():
|
|
59
|
+
fig = plt.figure(num=FIGURE, clear=True)
|
|
60
|
+
fig.set_size_inches(*figsize, forward=True)
|
|
61
|
+
if zoom_active():
|
|
62
|
+
# The chart draws its own title, so ipympl's header would repeat it; the
|
|
63
|
+
# toolbar is the point of this backend and the footer is the readout.
|
|
64
|
+
fig.canvas.header_visible = False
|
|
65
|
+
fig.canvas.toolbar_visible = True
|
|
66
|
+
fig.canvas.toolbar_position = "top"
|
|
67
|
+
fig.canvas.footer_visible = True
|
|
68
|
+
return fig
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def show(fig):
|
|
72
|
+
"""Display a figure: the live canvas when there is one, otherwise an image."""
|
|
73
|
+
if zoom_active():
|
|
74
|
+
display(fig.canvas) # the canvas is the widget - leave the figure open
|
|
75
|
+
else:
|
|
76
|
+
display(fig)
|
|
77
|
+
plt.close(fig)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def show_in_output(fig, output):
|
|
81
|
+
"""Replace a widget's chart without relying on notebook output capture."""
|
|
82
|
+
if zoom_active():
|
|
83
|
+
data = fig.canvas._repr_mimebundle_()
|
|
84
|
+
if isinstance(data, tuple):
|
|
85
|
+
data = data[0]
|
|
86
|
+
else:
|
|
87
|
+
try:
|
|
88
|
+
buffer = io.BytesIO()
|
|
89
|
+
fig.savefig(buffer, format="png", bbox_inches="tight")
|
|
90
|
+
data = {"image/png": base64.b64encode(buffer.getvalue()).decode("ascii")}
|
|
91
|
+
finally:
|
|
92
|
+
plt.close(fig)
|
|
93
|
+
output.outputs = ({"output_type": "display_data", "data": data, "metadata": {}},)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _shell():
|
|
97
|
+
try:
|
|
98
|
+
from IPython import get_ipython
|
|
99
|
+
except ImportError:
|
|
100
|
+
return None
|
|
101
|
+
return get_ipython()
|
tablelab/file_readers.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Reading a data file into a DataFrame.
|
|
2
|
+
|
|
3
|
+
Two entry points over the same logic: `load_table` for a path on disk, and
|
|
4
|
+
`read_uploaded_dataframe` for the bytes a FileUpload widget hands over. Pure -
|
|
5
|
+
no widgets and no app state, so the app and the CLI share them.
|
|
6
|
+
|
|
7
|
+
Headerless tables matter here: a csv whose first row is already data (ECG5000
|
|
8
|
+
and most of the UCR archive look like this) would otherwise lose that row to
|
|
9
|
+
pandas' column names.
|
|
10
|
+
"""
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import ssl
|
|
14
|
+
from urllib.error import URLError
|
|
15
|
+
from urllib.parse import urlsplit, unquote
|
|
16
|
+
from urllib.request import urlopen
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def table_location(path):
|
|
22
|
+
"""Keep web URLs intact; normalize local paths and obtain a reader filename."""
|
|
23
|
+
value = os.fspath(path).strip()
|
|
24
|
+
if value.lower().startswith(('http://', 'https://')):
|
|
25
|
+
parts = urlsplit(value)
|
|
26
|
+
if not parts.netloc:
|
|
27
|
+
raise ValueError('Enter a complete HTTP or HTTPS file URL.')
|
|
28
|
+
return value, unquote(parts.path.rsplit('/', 1)[-1]) or 'download.csv'
|
|
29
|
+
value = os.path.abspath(os.path.expanduser(value))
|
|
30
|
+
return value, os.path.basename(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _url_ssl_context():
|
|
34
|
+
"""Retain system/custom CAs and supplement missing Python roots with certifi."""
|
|
35
|
+
context = ssl.create_default_context()
|
|
36
|
+
try:
|
|
37
|
+
import certifi
|
|
38
|
+
except ImportError:
|
|
39
|
+
# Older source checkouts can still use the system certificate store.
|
|
40
|
+
pass
|
|
41
|
+
else:
|
|
42
|
+
context.load_verify_locations(cafile=certifi.where())
|
|
43
|
+
return context
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_table_source(path):
|
|
47
|
+
"""Read local or HTTP(S) bytes once, preserving URL query parameters."""
|
|
48
|
+
location, filename = table_location(path)
|
|
49
|
+
if location.lower().startswith(('http://', 'https://')):
|
|
50
|
+
try:
|
|
51
|
+
with urlopen(location, timeout=30, context=_url_ssl_context()) as response:
|
|
52
|
+
content_type = response.headers.get('Content-Type', '').lower()
|
|
53
|
+
content = response.read()
|
|
54
|
+
except (URLError, ssl.SSLCertVerificationError) as exc:
|
|
55
|
+
reason = getattr(exc, 'reason', exc)
|
|
56
|
+
if isinstance(reason, ssl.SSLCertVerificationError):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
'HTTPS certificate verification failed. In this notebook run '
|
|
59
|
+
'%pip install --upgrade certifi, then restart the kernel. '
|
|
60
|
+
'If your network uses a private certificate authority, configure '
|
|
61
|
+
'SSL_CERT_FILE with its trusted CA bundle. Verification remains enabled.'
|
|
62
|
+
) from exc
|
|
63
|
+
raise
|
|
64
|
+
if 'text/html' in content_type or content.lstrip()[:100].lower().startswith((b'<!doctype html', b'<html')):
|
|
65
|
+
raise ValueError('This URL returns a web page. Use a direct CSV or table download URL.')
|
|
66
|
+
else:
|
|
67
|
+
with open(location, 'rb') as file:
|
|
68
|
+
content = file.read()
|
|
69
|
+
return location, filename, content
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_table(path) -> pd.DataFrame:
|
|
73
|
+
"""Read a local file or HTTP(S) URL, sniffing delimited headers."""
|
|
74
|
+
_, filename, content = read_table_source(path)
|
|
75
|
+
return read_uploaded_dataframe(content, filename)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_uploaded_dataframe(content: bytes, filename: str) -> pd.DataFrame:
|
|
79
|
+
"""Best-effort reader for many formats. Requires optional deps for some types."""
|
|
80
|
+
ext = os.path.splitext((filename or "").lower())[1]
|
|
81
|
+
|
|
82
|
+
if ext in {".xlsx", ".xls"}:
|
|
83
|
+
try:
|
|
84
|
+
return pd.read_excel(io.BytesIO(content))
|
|
85
|
+
except ImportError as e:
|
|
86
|
+
raise RuntimeError("Reading Excel requires 'openpyxl'. Try: pip install openpyxl") from e
|
|
87
|
+
|
|
88
|
+
if ext == ".parquet":
|
|
89
|
+
try:
|
|
90
|
+
return pd.read_parquet(io.BytesIO(content))
|
|
91
|
+
except ImportError as e:
|
|
92
|
+
raise RuntimeError("Reading Parquet requires 'pyarrow'. Try: pip install pyarrow") from e
|
|
93
|
+
|
|
94
|
+
if ext in {".feather", ".arrow"}:
|
|
95
|
+
try:
|
|
96
|
+
import pyarrow.feather as feather
|
|
97
|
+
return feather.read_table(io.BytesIO(content)).to_pandas()
|
|
98
|
+
except ImportError as e:
|
|
99
|
+
raise RuntimeError("Reading Feather/Arrow requires 'pyarrow'. Try: pip install pyarrow") from e
|
|
100
|
+
|
|
101
|
+
if ext == ".json":
|
|
102
|
+
return pd.read_json(io.BytesIO(content), lines=False)
|
|
103
|
+
if ext in {".jsonl", ".ndjson"}:
|
|
104
|
+
return pd.read_json(io.BytesIO(content), lines=True)
|
|
105
|
+
|
|
106
|
+
# Delimited text, including the fallback for an unknown extension.
|
|
107
|
+
sep = {".tsv": "\t", ".tab": "\t", ".psv": "|"}.get(ext, ",")
|
|
108
|
+
header = 0 if _has_header(content, sep) else None
|
|
109
|
+
try:
|
|
110
|
+
return pd.read_csv(io.BytesIO(content), sep=sep, header=header)
|
|
111
|
+
except Exception:
|
|
112
|
+
return pd.read_csv(io.BytesIO(content), sep=None, engine="python", header=header)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _has_header(content: bytes, sep: str) -> bool:
|
|
116
|
+
"""A first row whose every field parses as a number is data, not column names."""
|
|
117
|
+
try:
|
|
118
|
+
first = pd.read_csv(io.BytesIO(content), sep=sep, header=None, nrows=1).iloc[0]
|
|
119
|
+
except Exception:
|
|
120
|
+
return True # unreadable first row: let pandas decide
|
|
121
|
+
return not all(_is_number(v) for v in first)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_number(v) -> bool:
|
|
125
|
+
try:
|
|
126
|
+
float(v)
|
|
127
|
+
return True
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
return False
|