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.
@@ -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()
@@ -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