normalize-tabular-data 0.1.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.
- normalize_tabular_data/__init__.py +18 -0
- normalize_tabular_data/__main__.py +4 -0
- normalize_tabular_data/app.py +644 -0
- normalize_tabular_data/io.py +187 -0
- normalize_tabular_data/ops.py +333 -0
- normalize_tabular_data/screens.py +568 -0
- normalize_tabular_data/widgets.py +227 -0
- normalize_tabular_data-0.1.0.dist-info/METADATA +164 -0
- normalize_tabular_data-0.1.0.dist-info/RECORD +12 -0
- normalize_tabular_data-0.1.0.dist-info/WHEEL +4 -0
- normalize_tabular_data-0.1.0.dist-info/entry_points.txt +3 -0
- normalize_tabular_data-0.1.0.dist-info/licenses/LICENSE +24 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""normalize-tabular-data: TUI for normalizing tabular data with polars."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
try:
|
|
8
|
+
from normalize_tabular_data.app import NormalizeApp
|
|
9
|
+
except ImportError as exc: # helpful message if an optional engine is missing
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
print(
|
|
13
|
+
f"normalize-tabular-data is missing a dependency ({exc}).\n"
|
|
14
|
+
"Reinstall with: uv tool install --force --reinstall normalize-tabular-data",
|
|
15
|
+
file=sys.stderr,
|
|
16
|
+
)
|
|
17
|
+
raise SystemExit(1)
|
|
18
|
+
NormalizeApp().run()
|
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""The Textual application: preview table + column sidebar + step pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import random
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from textual import on
|
|
12
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.color import Color
|
|
15
|
+
from textual.command import CommandPalette
|
|
16
|
+
from textual.content import Content
|
|
17
|
+
from textual.containers import Horizontal
|
|
18
|
+
from textual.events import Key
|
|
19
|
+
from textual.screen import Screen
|
|
20
|
+
from textual.widgets import DataTable, Header, Label, ListItem, ListView
|
|
21
|
+
|
|
22
|
+
from normalize_tabular_data import io
|
|
23
|
+
from normalize_tabular_data.io import SCRIPT_SUFFIX
|
|
24
|
+
from normalize_tabular_data.ops import (
|
|
25
|
+
OP_REGISTRY,
|
|
26
|
+
PLAY_REGISTRY,
|
|
27
|
+
RENAME_OP,
|
|
28
|
+
Operation,
|
|
29
|
+
Pipeline,
|
|
30
|
+
analyze_column,
|
|
31
|
+
)
|
|
32
|
+
from normalize_tabular_data.screens import (
|
|
33
|
+
ModalDialog,
|
|
34
|
+
OpenFileModal,
|
|
35
|
+
OpParamsModal,
|
|
36
|
+
RenameColumnModal,
|
|
37
|
+
SaveModal,
|
|
38
|
+
ScriptNameModal,
|
|
39
|
+
ScriptPickerModal,
|
|
40
|
+
SheetPickerModal,
|
|
41
|
+
)
|
|
42
|
+
from normalize_tabular_data.widgets import ColumnSidebar, MenuFooter, StepsBar
|
|
43
|
+
|
|
44
|
+
PREVIEW_ROWS = 250
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CommandMenu(CommandPalette):
|
|
48
|
+
"""The command palette minus its search row: a pure arrow-over/Enter
|
|
49
|
+
command menu (the search field is hidden in CSS).
|
|
50
|
+
|
|
51
|
+
The hidden field keeps the palette internals honest — the list is
|
|
52
|
+
populated with every command by the mount-time empty gather — but no
|
|
53
|
+
focus is given to it, so typing does nothing: there is no search.
|
|
54
|
+
Arrow keys move the highlight through the screen's own 'command_list'
|
|
55
|
+
actions; Enter runs the highlighted command (the hidden input's
|
|
56
|
+
Submit gesture replaced by a screen binding)."""
|
|
57
|
+
|
|
58
|
+
AUTO_FOCUS = ""
|
|
59
|
+
"""Focus nothing: the (hidden) search input must stay un-focused so
|
|
60
|
+
keys reach the screen bindings and typing can't re-run the search."""
|
|
61
|
+
# (None would fall through to App.AUTO_FOCUS "*", which would focus it)
|
|
62
|
+
|
|
63
|
+
BINDINGS = [
|
|
64
|
+
*CommandPalette.BINDINGS,
|
|
65
|
+
Binding("enter", "accept_highlighted", "", show=False),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def action_accept_highlighted(self) -> None:
|
|
69
|
+
"""Run the highlighted command (menu-pick mode)."""
|
|
70
|
+
self._select_or_command()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PreviewTable(DataTable):
|
|
74
|
+
"""Preview whose Left/Right keys scroll a whole column per press.
|
|
75
|
+
|
|
76
|
+
With the row cursor, DataTable's default Left/Right fall through to
|
|
77
|
+
one-character scrolling; this snaps to column start boundaries instead."""
|
|
78
|
+
|
|
79
|
+
def _column_offsets(self) -> list[float]:
|
|
80
|
+
widths = [col.get_render_width(self) for col in self.columns.values()]
|
|
81
|
+
offsets = [0.0]
|
|
82
|
+
for w in widths:
|
|
83
|
+
offsets.append(offsets[-1] + w)
|
|
84
|
+
return offsets
|
|
85
|
+
|
|
86
|
+
def _scroll_to_column(self, direction: int) -> None:
|
|
87
|
+
offsets = self._column_offsets()
|
|
88
|
+
if len(offsets) < 2:
|
|
89
|
+
return
|
|
90
|
+
eps = 0.5
|
|
91
|
+
idx = bisect.bisect_right(offsets, self.scroll_x + eps) - 1
|
|
92
|
+
if direction > 0:
|
|
93
|
+
# land on the next boundary even if the current column is only
|
|
94
|
+
# partly scrolled past
|
|
95
|
+
target = min(idx + 1, len(offsets) - 2)
|
|
96
|
+
else:
|
|
97
|
+
# first finish revealing the current column, then step back one
|
|
98
|
+
target = idx - 1 if self.scroll_x <= offsets[idx] + eps else idx
|
|
99
|
+
target = max(target, 0)
|
|
100
|
+
self.scroll_to(x=offsets[target], animate=False)
|
|
101
|
+
|
|
102
|
+
def action_cursor_left(self) -> None:
|
|
103
|
+
self._scroll_to_column(-1)
|
|
104
|
+
|
|
105
|
+
def action_cursor_right(self) -> None:
|
|
106
|
+
self._scroll_to_column(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class OpChooserModal(ModalDialog):
|
|
110
|
+
"""Pick an operation from the registry; dismisses with the op's key.
|
|
111
|
+
|
|
112
|
+
Docked over the sidebar, narrower than it, so the preview table keeps
|
|
113
|
+
showing data rows while the chooser is open."""
|
|
114
|
+
|
|
115
|
+
dialog_title = "Choose operation"
|
|
116
|
+
|
|
117
|
+
DEFAULT_CSS = """
|
|
118
|
+
OpChooserModal {
|
|
119
|
+
align: left middle;
|
|
120
|
+
}
|
|
121
|
+
OpChooserModal > Vertical {
|
|
122
|
+
width: 38;
|
|
123
|
+
}
|
|
124
|
+
OpChooserModal ListView {
|
|
125
|
+
width: 32;
|
|
126
|
+
height: auto;
|
|
127
|
+
}
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self) -> None:
|
|
131
|
+
super().__init__()
|
|
132
|
+
# hotkey letter (lowercase) -> op key; letters are unique across ops
|
|
133
|
+
self._hotkeys: dict[str, str] = {}
|
|
134
|
+
|
|
135
|
+
def _marked_title(self, op_key: str, title: str, hotkey: str = "") -> str:
|
|
136
|
+
"""Return "Normalize (d)ates"-style markup: the op's hotkey letter
|
|
137
|
+
highlighted where it sits in the title (keeping its case). Ops
|
|
138
|
+
without a designated hotkey claim the first unused letter in the
|
|
139
|
+
title instead; plain title if no letter can be claimed."""
|
|
140
|
+
if hotkey:
|
|
141
|
+
i = title.lower().find(hotkey.lower())
|
|
142
|
+
else:
|
|
143
|
+
i = next(
|
|
144
|
+
(
|
|
145
|
+
i
|
|
146
|
+
for i, c in enumerate(title)
|
|
147
|
+
if c.isalnum() and c.lower() not in self._hotkeys
|
|
148
|
+
),
|
|
149
|
+
-1,
|
|
150
|
+
)
|
|
151
|
+
if i < 0:
|
|
152
|
+
return title
|
|
153
|
+
self._hotkeys[title[i].lower()] = op_key
|
|
154
|
+
return f"{title[:i]}([cyan]{title[i]}[/cyan]){title[i + 1:]}"
|
|
155
|
+
|
|
156
|
+
def compose_body(self) -> ComposeResult:
|
|
157
|
+
items = [
|
|
158
|
+
ListItem(
|
|
159
|
+
Label(self._marked_title(op.key, op.title, op.hotkey)), name=op.key
|
|
160
|
+
)
|
|
161
|
+
for op in OP_REGISTRY.values()
|
|
162
|
+
]
|
|
163
|
+
yield ListView(*items, id="oplist")
|
|
164
|
+
|
|
165
|
+
def on_mount(self) -> None:
|
|
166
|
+
self.query_one("#oplist", ListView).focus()
|
|
167
|
+
|
|
168
|
+
@on(Key)
|
|
169
|
+
def _hotkey_pressed(self, event: Key) -> None:
|
|
170
|
+
# pressing the marked letter selects that operation directly
|
|
171
|
+
if event.character and (op_key := self._hotkeys.get(event.character.lower())):
|
|
172
|
+
event.stop()
|
|
173
|
+
event.prevent_default()
|
|
174
|
+
self.post_result(op_key)
|
|
175
|
+
|
|
176
|
+
@on(ListView.Selected)
|
|
177
|
+
def on_op_selected(self, event: ListView.Selected) -> None:
|
|
178
|
+
if event.item.name:
|
|
179
|
+
self.post_result(event.item.name)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class AppHeader(Header):
|
|
183
|
+
"""Header with the application name docked hard right (it previously
|
|
184
|
+
shared the centered title slot with the file/row info)."""
|
|
185
|
+
|
|
186
|
+
DEFAULT_CSS = """
|
|
187
|
+
AppHeader > Label.app_name {
|
|
188
|
+
dock: right;
|
|
189
|
+
height: 1;
|
|
190
|
+
margin-right: 2;
|
|
191
|
+
padding: 0 1;
|
|
192
|
+
color: $foreground-muted;
|
|
193
|
+
}
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def compose(self) -> ComposeResult:
|
|
197
|
+
yield from super().compose()
|
|
198
|
+
yield Label("normalize-tabular-data", classes="app_name")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class MainScreen(Screen[None]):
|
|
202
|
+
BINDINGS = [
|
|
203
|
+
("f", "app.open", "(F)ile"),
|
|
204
|
+
("o", "app.choose_op", "(O)peration"),
|
|
205
|
+
("p", "app.play_script", "(P)lay script"),
|
|
206
|
+
("u", "app.undo", "(U)ndo"),
|
|
207
|
+
("r", "app.redo", "(R)edo"),
|
|
208
|
+
("s", "app.save", "(S)ave"),
|
|
209
|
+
("q", "app.quit_app", "(Q)uit"),
|
|
210
|
+
("tab", "app.focus_next", ""),
|
|
211
|
+
("shift+tab", "app.focus_previous", ""),
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def compose(self) -> ComposeResult:
|
|
215
|
+
yield AppHeader()
|
|
216
|
+
with Horizontal():
|
|
217
|
+
yield ColumnSidebar(id="sidebar")
|
|
218
|
+
yield PreviewTable(show_cursor=True, id="preview", cursor_type="row")
|
|
219
|
+
yield StepsBar(id="steps")
|
|
220
|
+
yield MenuFooter()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class NormalizeApp(App[None]):
|
|
224
|
+
TITLE = "normalize-tabular-data"
|
|
225
|
+
|
|
226
|
+
CSS = """
|
|
227
|
+
#preview { border: round $accent; scrollbar-size: 0 0; }
|
|
228
|
+
DataTable { width: 1fr; }
|
|
229
|
+
/* notification toasts: bottom-left, lifted clear of the panes' bottom
|
|
230
|
+
* border row (default rack is bottom-right, 1 line up, over the border);
|
|
231
|
+
* nudged one row and one column inward from the screen corner */
|
|
232
|
+
ToastRack {
|
|
233
|
+
align: left bottom;
|
|
234
|
+
margin-bottom: 2;
|
|
235
|
+
padding-left: 1;
|
|
236
|
+
}
|
|
237
|
+
ToastHolder {
|
|
238
|
+
align-horizontal: left;
|
|
239
|
+
}
|
|
240
|
+
/* infobox itself: a full outline in the severity color (replacing the
|
|
241
|
+
* default left-hand bar) on a background of its own — the theme accent
|
|
242
|
+
* at low strength over the theme background, warm against the cool
|
|
243
|
+
* slate of the panes ($infobox-bg comes from get_theme_variable_defaults) */
|
|
244
|
+
Toast {
|
|
245
|
+
background: $infobox-bg;
|
|
246
|
+
border: round $accent-muted;
|
|
247
|
+
&:ansi {
|
|
248
|
+
background: $infobox-bg;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
Toast.-information {
|
|
252
|
+
border: round $success;
|
|
253
|
+
}
|
|
254
|
+
Toast.-warning {
|
|
255
|
+
border: round $warning;
|
|
256
|
+
}
|
|
257
|
+
Toast.-error {
|
|
258
|
+
border: round $error;
|
|
259
|
+
}
|
|
260
|
+
/* command menu (palette minus its search row): centered with 5-space
|
|
261
|
+
* side margins and 3 rows of air above the drop-down; arrows move the
|
|
262
|
+
* highlight, Enter runs the highlighted command */
|
|
263
|
+
CommandPalette #--input {
|
|
264
|
+
display: none;
|
|
265
|
+
}
|
|
266
|
+
CommandPalette > Vertical {
|
|
267
|
+
margin-top: 3;
|
|
268
|
+
margin-left: 5;
|
|
269
|
+
margin-right: 5;
|
|
270
|
+
}
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(self) -> None:
|
|
274
|
+
super().__init__()
|
|
275
|
+
self.path: Path | None = None
|
|
276
|
+
self.pipeline: Pipeline | None = None
|
|
277
|
+
self.analyzed: dict[str, Any] = {}
|
|
278
|
+
# operations applied since this file was opened: (op key, params);
|
|
279
|
+
# the raw material for a saved ".ntd" script
|
|
280
|
+
self.op_log: list[tuple[str, dict[str, Any]]] = []
|
|
281
|
+
# preview sampling seed, redrawn per load so previews are a random
|
|
282
|
+
# sample but stable across refreshes within one load
|
|
283
|
+
self._preview_seed: int = 0
|
|
284
|
+
|
|
285
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
286
|
+
"""Palette system commands minus Quit and Screenshot: quitting
|
|
287
|
+
belongs to the (Q) footer key, and screenshots aren't part of this
|
|
288
|
+
tool's workflow."""
|
|
289
|
+
yield from (
|
|
290
|
+
command
|
|
291
|
+
for command in super().get_system_commands(screen)
|
|
292
|
+
if command.title not in ("Quit", "Screenshot")
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def action_change_theme(self) -> None:
|
|
296
|
+
"""The Theme command (and its keybinding): same no-search menu,
|
|
297
|
+
over the theme provider instead of the system commands."""
|
|
298
|
+
from textual.theme import ThemeProvider
|
|
299
|
+
|
|
300
|
+
self.push_screen(CommandMenu(id="--theme-picker", providers=[ThemeProvider]))
|
|
301
|
+
|
|
302
|
+
def action_command_palette(self) -> None:
|
|
303
|
+
"""Open the command menu: the palette with no search field and a
|
|
304
|
+
3-row top margin above its drop-down list (CSS below)."""
|
|
305
|
+
self.push_screen(CommandMenu(id="--command-palette"))
|
|
306
|
+
|
|
307
|
+
def get_theme_variable_defaults(self) -> dict[str, str]:
|
|
308
|
+
"""Define $infobox-bg: the theme accent blended at low strength
|
|
309
|
+
into the theme background — warm against the cool slate of the
|
|
310
|
+
regular panes, so the infobox reads as its own surface."""
|
|
311
|
+
variables = super().get_theme_variable_defaults()
|
|
312
|
+
theme_colors = self.current_theme.to_color_system().generate()
|
|
313
|
+
background = Color.parse(theme_colors["background"])
|
|
314
|
+
accent = theme_colors.get("accent")
|
|
315
|
+
if accent:
|
|
316
|
+
variables["infobox-bg"] = Color.blend(
|
|
317
|
+
background, Color.parse(accent), 0.18
|
|
318
|
+
).hex
|
|
319
|
+
else: # no accent in this theme: a plain lift of the background
|
|
320
|
+
variables["infobox-bg"] = background.lighten(2).hex
|
|
321
|
+
return variables
|
|
322
|
+
|
|
323
|
+
def format_title(self, title: str, sub_title: str) -> Content:
|
|
324
|
+
# centered header slot: file/row info only — the app name lives
|
|
325
|
+
# docked hard right in the header
|
|
326
|
+
return Content(sub_title)
|
|
327
|
+
|
|
328
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
329
|
+
# menus that need a loaded table are grayed out in the footer (None)
|
|
330
|
+
# while there is no file, and their keys do nothing
|
|
331
|
+
if action in ("play_script", "choose_op", "undo", "redo", "save"):
|
|
332
|
+
return True if self.pipeline is not None else None
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def current_columns(self) -> list[str]:
|
|
337
|
+
if self.pipeline is None:
|
|
338
|
+
return []
|
|
339
|
+
return list(self.pipeline.current().columns)
|
|
340
|
+
|
|
341
|
+
def on_mount(self) -> None:
|
|
342
|
+
self.push_screen(MainScreen())
|
|
343
|
+
self._refresh_steps()
|
|
344
|
+
|
|
345
|
+
# --- data plumbing ---------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def load_path(self, path: Path, sheet: str | None = None) -> None:
|
|
348
|
+
# detect/sheet probing also runs in a modal callback; keep any failure
|
|
349
|
+
# in here as a TUI toast instead of a terminal traceback
|
|
350
|
+
try:
|
|
351
|
+
fmt = io.detect_format(path)
|
|
352
|
+
if fmt == "xlsx" and sheet is None:
|
|
353
|
+
sheets = io.excel_sheets(path)
|
|
354
|
+
else:
|
|
355
|
+
self._load_complete(path, fmt, sheet)
|
|
356
|
+
return
|
|
357
|
+
except Exception as exc: # user-facing: unsupported type, bad xlsx, ...
|
|
358
|
+
self.notify(f"Could not read {path.name}: {exc}", severity="error")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
def sheet_callback(chosen: str | None) -> None:
|
|
362
|
+
if not chosen:
|
|
363
|
+
return
|
|
364
|
+
self._load_complete(path, fmt, chosen)
|
|
365
|
+
|
|
366
|
+
if len(sheets) > 1:
|
|
367
|
+
self.push_screen(SheetPickerModal(sheets), sheet_callback)
|
|
368
|
+
return
|
|
369
|
+
self._load_complete(path, fmt, sheet)
|
|
370
|
+
|
|
371
|
+
def _load_complete(self, path: Path, fmt: str, sheet: str | None) -> None:
|
|
372
|
+
try:
|
|
373
|
+
df = io.read_table(path, fmt, sheet)
|
|
374
|
+
except Exception as exc: # user-facing message, keep the TUI alive
|
|
375
|
+
self.notify(f"Could not read {path.name}: {exc}", severity="error")
|
|
376
|
+
return
|
|
377
|
+
self.path = path
|
|
378
|
+
self.pipeline = Pipeline(source=df)
|
|
379
|
+
# opening a file starts a fresh operation log for it
|
|
380
|
+
self.op_log = []
|
|
381
|
+
self._preview_seed = random.getrandbits(64)
|
|
382
|
+
self.analyze_columns()
|
|
383
|
+
self.refresh_preview()
|
|
384
|
+
self.refresh_sidebar()
|
|
385
|
+
self._refresh_steps()
|
|
386
|
+
# pipeline is now set: re-evaluate the footer's enabled keys
|
|
387
|
+
self.screen.refresh_bindings()
|
|
388
|
+
self.notify(f"Loaded {path.name} ({df.height:,} rows x {df.width} columns)")
|
|
389
|
+
|
|
390
|
+
def analyze_columns(self) -> None:
|
|
391
|
+
if self.pipeline is None:
|
|
392
|
+
return
|
|
393
|
+
df = self.pipeline.current()
|
|
394
|
+
self.analyzed = {name: analyze_column(df, name) for name in df.columns}
|
|
395
|
+
|
|
396
|
+
# --- rendering --------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def refresh_preview(self) -> None:
|
|
399
|
+
try:
|
|
400
|
+
table = self.screen.query_one("#preview", DataTable)
|
|
401
|
+
except Exception:
|
|
402
|
+
return
|
|
403
|
+
if self.pipeline is None:
|
|
404
|
+
return
|
|
405
|
+
full = self.pipeline.current()
|
|
406
|
+
# large tables preview a random sample (same rows across refreshes
|
|
407
|
+
# within one load); small tables show everything in file order
|
|
408
|
+
df = (
|
|
409
|
+
full.sample(PREVIEW_ROWS, seed=self._preview_seed)
|
|
410
|
+
if full.height > PREVIEW_ROWS
|
|
411
|
+
else full
|
|
412
|
+
)
|
|
413
|
+
table.clear(columns=True)
|
|
414
|
+
table.add_columns(*df.columns)
|
|
415
|
+
for row in df.iter_rows(named=False):
|
|
416
|
+
table.add_row(*[_cell(v) for v in row])
|
|
417
|
+
self.screen.sub_title = (
|
|
418
|
+
f"{self.path.name if self.path else ''} — "
|
|
419
|
+
f"{df.height:,} of {self.pipeline.current().height:,} rows"
|
|
420
|
+
" — click column to rename"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def refresh_sidebar(self) -> None:
|
|
424
|
+
try:
|
|
425
|
+
sidebar = self.screen.query_one("#sidebar", ColumnSidebar)
|
|
426
|
+
except Exception:
|
|
427
|
+
return
|
|
428
|
+
sidebar.set_infos(list(self.analyzed.values()))
|
|
429
|
+
|
|
430
|
+
def _refresh_steps(self) -> None:
|
|
431
|
+
try:
|
|
432
|
+
steps = self.screen.query_one("#steps", StepsBar)
|
|
433
|
+
except Exception:
|
|
434
|
+
return
|
|
435
|
+
steps.set_steps(self.pipeline.step_summary() if self.pipeline else [])
|
|
436
|
+
|
|
437
|
+
def refresh_all(self) -> None:
|
|
438
|
+
self.analyze_columns()
|
|
439
|
+
self.refresh_preview()
|
|
440
|
+
self.refresh_sidebar()
|
|
441
|
+
self._refresh_steps()
|
|
442
|
+
|
|
443
|
+
# --- actions ------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def action_open(self) -> None:
|
|
446
|
+
def handle_result(chosen: Path | None) -> None:
|
|
447
|
+
if chosen:
|
|
448
|
+
self.load_path(chosen)
|
|
449
|
+
|
|
450
|
+
self.push_screen(OpenFileModal(), handle_result)
|
|
451
|
+
|
|
452
|
+
def action_choose_op(self) -> None:
|
|
453
|
+
if self.pipeline is None:
|
|
454
|
+
self.notify("Open a file first (o)", severity="warning")
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
def handle_result(key: str | None) -> None:
|
|
458
|
+
if key is None:
|
|
459
|
+
return
|
|
460
|
+
op = OP_REGISTRY[key]
|
|
461
|
+
|
|
462
|
+
def params_result(params: dict[str, Any] | None) -> None:
|
|
463
|
+
if params:
|
|
464
|
+
self._apply_now(op, params)
|
|
465
|
+
|
|
466
|
+
self.push_screen(OpParamsModal(op), params_result)
|
|
467
|
+
|
|
468
|
+
self.push_screen(OpChooserModal(), handle_result)
|
|
469
|
+
|
|
470
|
+
def action_play_script(self) -> None:
|
|
471
|
+
if self.pipeline is None:
|
|
472
|
+
self.notify("Open a file first (o)", severity="warning")
|
|
473
|
+
return
|
|
474
|
+
# scripts usually live beside the table they were saved from
|
|
475
|
+
start = self.path.parent if self.path else None
|
|
476
|
+
|
|
477
|
+
def handle_result(chosen: Path | None) -> None:
|
|
478
|
+
if chosen:
|
|
479
|
+
self.play_script_file(chosen)
|
|
480
|
+
|
|
481
|
+
self.push_screen(ScriptPickerModal(start), handle_result)
|
|
482
|
+
|
|
483
|
+
def play_script_file(self, script_path: Path) -> None:
|
|
484
|
+
"""Apply a script's steps to the open table, all-or-nothing.
|
|
485
|
+
|
|
486
|
+
On the first step that is impossible for the currently loaded file
|
|
487
|
+
(unknown operation, validation failure, apply error) the alert names
|
|
488
|
+
the failing step and everything applied from the script so far is
|
|
489
|
+
rolled back to the state before it started."""
|
|
490
|
+
try:
|
|
491
|
+
steps = io.read_script(script_path)
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
self.notify(f"Could not read {script_path.name}: {exc}", severity="error")
|
|
494
|
+
return
|
|
495
|
+
if not steps:
|
|
496
|
+
self.notify(
|
|
497
|
+
f"{script_path.name} contains no operations", severity="warning"
|
|
498
|
+
)
|
|
499
|
+
return
|
|
500
|
+
started_applied = len(self.pipeline.applied)
|
|
501
|
+
started_log = len(self.op_log)
|
|
502
|
+
for index, (key, params) in enumerate(steps, 1):
|
|
503
|
+
op = PLAY_REGISTRY.get(key)
|
|
504
|
+
if op is None:
|
|
505
|
+
self._rollback_script(
|
|
506
|
+
index,
|
|
507
|
+
started_applied,
|
|
508
|
+
started_log,
|
|
509
|
+
f"Step {index}: {key!r} is not a known operation",
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
try:
|
|
513
|
+
self.pipeline.apply(op, params)
|
|
514
|
+
# the pipeline refolds lazily; fold now so an op-specific
|
|
515
|
+
# error (e.g. unparseable dates) rolls back this step too
|
|
516
|
+
self.pipeline.current()
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
self._rollback_script(
|
|
519
|
+
index,
|
|
520
|
+
started_applied,
|
|
521
|
+
started_log,
|
|
522
|
+
f"Step {index} of {len(steps)} ({op.title}) failed: {exc}",
|
|
523
|
+
)
|
|
524
|
+
return
|
|
525
|
+
self.op_log.append((key, dict(params)))
|
|
526
|
+
self.refresh_all()
|
|
527
|
+
count = len(steps)
|
|
528
|
+
self.notify(
|
|
529
|
+
f"Applied {count} step{'s' if count != 1 else ''} from {script_path.name}"
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def _rollback_script(
|
|
533
|
+
self, step: int, started_applied: int, started_log: int, reason: str
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Undo everything a partially played script applied, then alert."""
|
|
536
|
+
undid = len(self.pipeline.applied) - started_applied
|
|
537
|
+
del self.pipeline.applied[started_applied:]
|
|
538
|
+
self.pipeline.redo_stack.clear()
|
|
539
|
+
del self.op_log[started_log:]
|
|
540
|
+
self.refresh_all()
|
|
541
|
+
detail = (
|
|
542
|
+
f" — rolled back {undid} step{'s' if undid != 1 else ''}" if undid else ""
|
|
543
|
+
)
|
|
544
|
+
self.notify(f"{reason}{detail}", severity="error")
|
|
545
|
+
|
|
546
|
+
def _apply_now(self, op: Operation, params: dict[str, Any]) -> None:
|
|
547
|
+
try:
|
|
548
|
+
self.pipeline.apply(op, params)
|
|
549
|
+
except ValueError as exc:
|
|
550
|
+
self.notify(str(exc), severity="error")
|
|
551
|
+
return
|
|
552
|
+
self.op_log.append((op.key, dict(params)))
|
|
553
|
+
self.refresh_all()
|
|
554
|
+
self.notify(f"Applied: {op.title}", severity="information")
|
|
555
|
+
|
|
556
|
+
@on(DataTable.HeaderSelected)
|
|
557
|
+
def _on_header_selected(self, event: DataTable.HeaderSelected) -> None:
|
|
558
|
+
"""Clicking a column header opens the rename dialog for that column."""
|
|
559
|
+
if self.pipeline is None:
|
|
560
|
+
return
|
|
561
|
+
old_name = str(event.label) or str(event.column_key)
|
|
562
|
+
if old_name not in self.pipeline.current().columns:
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
def handle_result(new_name: str | None) -> None:
|
|
566
|
+
if not new_name or new_name == old_name:
|
|
567
|
+
return
|
|
568
|
+
if new_name in self.pipeline.current().columns:
|
|
569
|
+
self.notify(f"{new_name!r} already exists", severity="error")
|
|
570
|
+
return
|
|
571
|
+
self._apply_now(RENAME_OP, {"column": old_name, "new_name": new_name})
|
|
572
|
+
|
|
573
|
+
self.push_screen(RenameColumnModal(old_name), handle_result)
|
|
574
|
+
|
|
575
|
+
def action_undo(self) -> None:
|
|
576
|
+
if self.pipeline is None or not self.pipeline.undo():
|
|
577
|
+
self.notify("Nothing to undo", severity="warning")
|
|
578
|
+
return
|
|
579
|
+
self.refresh_all()
|
|
580
|
+
|
|
581
|
+
def action_redo(self) -> None:
|
|
582
|
+
if self.pipeline is None or not self.pipeline.redo():
|
|
583
|
+
self.notify("Nothing to redo", severity="warning")
|
|
584
|
+
return
|
|
585
|
+
self.refresh_all()
|
|
586
|
+
|
|
587
|
+
def action_save(self) -> None:
|
|
588
|
+
if self.pipeline is None:
|
|
589
|
+
self.notify("Open a file first (o)", severity="warning")
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
def handle_result(chosen: tuple[Path, str, bool] | None) -> None:
|
|
593
|
+
if not chosen:
|
|
594
|
+
return
|
|
595
|
+
target, fmt, want_script = chosen
|
|
596
|
+
try:
|
|
597
|
+
io.write_table(self.pipeline.current(), target, fmt)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
self.notify(f"Save failed: {exc}", severity="error")
|
|
600
|
+
return
|
|
601
|
+
self.notify(f"Saved {target}")
|
|
602
|
+
if want_script:
|
|
603
|
+
self._offer_script_save(target)
|
|
604
|
+
|
|
605
|
+
suggested = self.path
|
|
606
|
+
if suggested is not None:
|
|
607
|
+
suggested = suggested.with_stem(suggested.stem + "-normalized")
|
|
608
|
+
self.push_screen(SaveModal(suggested), handle_result)
|
|
609
|
+
|
|
610
|
+
def _offer_script_save(self, table_path: Path) -> None:
|
|
611
|
+
suggested = table_path.with_suffix(SCRIPT_SUFFIX).expanduser()
|
|
612
|
+
|
|
613
|
+
def handle_result(script_path: Path | None) -> None:
|
|
614
|
+
if script_path is None:
|
|
615
|
+
return
|
|
616
|
+
try:
|
|
617
|
+
io.write_script(
|
|
618
|
+
script_path,
|
|
619
|
+
self.op_log,
|
|
620
|
+
header_fields={
|
|
621
|
+
"source": str(self.path) if self.path else "",
|
|
622
|
+
"table": str(table_path),
|
|
623
|
+
"saved": _dt.datetime.now().isoformat(timespec="seconds"),
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
self.notify(f"Script save failed: {exc}", severity="error")
|
|
628
|
+
return
|
|
629
|
+
self.notify(f"Saved script {script_path}")
|
|
630
|
+
|
|
631
|
+
self.push_screen(ScriptNameModal(suggested), handle_result)
|
|
632
|
+
|
|
633
|
+
def action_quit_app(self) -> None:
|
|
634
|
+
self.exit()
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _cell(value: Any) -> str:
|
|
638
|
+
if value is None:
|
|
639
|
+
return "<NULL>"
|
|
640
|
+
if isinstance(value, _dt.datetime | _dt.date):
|
|
641
|
+
return value.isoformat()
|
|
642
|
+
if isinstance(value, float):
|
|
643
|
+
return f"{value:.6g}"
|
|
644
|
+
return str(value)
|