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/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
+ ]
@@ -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,5 @@
1
+ """Widget helpers used to compose the tablelab interface."""
2
+
3
+ from ..filepicker import FilePicker
4
+
5
+ __all__ = ["FilePicker"]
@@ -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 = ()