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.
@@ -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,4 @@
1
+ from normalize_tabular_data import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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)