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,568 @@
1
+ """Modal screens: file open/save, operation parameters, date sample check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.syntax import Syntax as RichSyntax
8
+ from textual import on
9
+ from textual.color import Color
10
+ from textual.app import ComposeResult
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import (
14
+ Button,
15
+ Checkbox,
16
+ Input,
17
+ Label,
18
+ ListItem,
19
+ ListView,
20
+ Select,
21
+ SelectionList,
22
+ Static,
23
+ )
24
+
25
+ from normalize_tabular_data.io import SCRIPT_SUFFIX, WRITE_SUFFIXES
26
+ from normalize_tabular_data.ops import Operation
27
+
28
+
29
+ class ModalDialog(ModalScreen):
30
+ """Shared chrome: centered bordered box; Escape cancels."""
31
+
32
+ DEFAULT_CSS = """
33
+ ModalDialog { align: center middle; }
34
+ ModalDialog > Vertical {
35
+ background: $surface;
36
+ border: round $accent;
37
+ padding: 1 2;
38
+ width: auto;
39
+ height: auto;
40
+ max-width: 96;
41
+ max-height: 90%;
42
+ }
43
+ .buttons { align-horizontal: right; margin-top: 1; height: auto; }
44
+ Button { margin-left: 1; width: auto; min-width: 6 !important; }
45
+ Label.help { color: $text-muted; }
46
+ #path_input, #save_input { width: 64; }
47
+ ListView { height: 12; width: 64; }
48
+ """
49
+
50
+ BINDINGS = [("escape", "cancel", "Cancel")]
51
+
52
+ dialog_title: str = "Dialog"
53
+
54
+ def compose(self) -> ComposeResult:
55
+ with Vertical():
56
+ yield Label(self.dialog_title)
57
+ yield from self.compose_body()
58
+ with Horizontal(classes="buttons"):
59
+ yield Button("OK", id="btn_ok", variant="primary")
60
+ yield Button("Cancel", id="btn_cancel")
61
+
62
+ def compose_body(self) -> ComposeResult:
63
+ return ()
64
+ yield NotImplemented
65
+
66
+ def post_result(self, value) -> None:
67
+ self.dismiss(value)
68
+
69
+ @on(Button.Pressed, "#btn_ok")
70
+ def _ok_pressed(self) -> None:
71
+ self.action_ok()
72
+
73
+ @on(Button.Pressed, "#btn_cancel")
74
+ def _cancel_pressed(self) -> None:
75
+ self.action_cancel()
76
+
77
+ def action_ok(self) -> None:
78
+ self.post_result(None)
79
+
80
+ def action_cancel(self) -> None:
81
+ self.post_result(None)
82
+
83
+
84
+ class OpenFileModal(ModalDialog):
85
+ """Type a path or browse the current directory; choose a file."""
86
+
87
+ dialog_title = "Open file"
88
+
89
+ def __init__(self, start: Path | None = None) -> None:
90
+ super().__init__()
91
+ self.dir = (start or Path.cwd()).resolve()
92
+
93
+ def compose_body(self) -> ComposeResult:
94
+ yield Input(placeholder="type a path, then press Enter", id="path_input")
95
+ yield ListView(id="opendir")
96
+
97
+ def on_mount(self) -> None:
98
+ self._refresh_dir(self.dir)
99
+ self.query_one("#path_input", Input).focus()
100
+
101
+ def _refresh_dir(self, directory: Path) -> None:
102
+ self.dir = directory
103
+ input_widget = self.query_one("#path_input", Input)
104
+ input_widget.placeholder = f"path under {directory}"
105
+ listing = self.query_one("#opendir", ListView)
106
+ listing.clear()
107
+ listing.extend(self._entries(directory))
108
+
109
+ def _entries(self, directory: Path) -> list[ListItem]:
110
+ children = sorted(
111
+ (p for p in directory.iterdir() if not p.name.startswith(".")),
112
+ key=lambda p: (not p.is_dir(), p.name.lower()),
113
+ )
114
+ items = [ListItem(Label("../"), name=str(directory.parent.resolve()))]
115
+ items += [
116
+ ListItem(Label(p.name + ("/" if p.is_dir() else "")), name=str(p))
117
+ for p in children
118
+ ]
119
+ return items
120
+
121
+ @on(ListView.Selected)
122
+ def on_list_selected(self, event: ListView.Selected) -> None:
123
+ if not event.item.name:
124
+ return
125
+ chosen = Path(event.item.name)
126
+ if chosen.is_dir():
127
+ self._refresh_dir(chosen)
128
+ return
129
+ self.post_result(chosen)
130
+
131
+ @on(Input.Submitted, "#path_input")
132
+ def on_input_submitted(self) -> None:
133
+ raw = self.query_one("#path_input", Input).value.strip()
134
+ if raw:
135
+ self.post_result(Path(raw).expanduser())
136
+
137
+
138
+ class ScriptPickerModal(OpenFileModal):
139
+ """Browse for an operation script file to play against the open table.
140
+
141
+ As the browse list highlight moves (or a path is typed), a syntax-
142
+ highlighted preview of the `.ntd` file shows underneath."""
143
+
144
+ dialog_title = "Play script"
145
+
146
+ DEFAULT_CSS = """
147
+ ScriptPickerModal > Vertical > Static#script_preview {
148
+ height: 10;
149
+ width: 64;
150
+ margin-top: 1;
151
+ background: $boost;
152
+ }
153
+ """
154
+
155
+ PREVIEW_LINES = 40
156
+
157
+ preview_text: str = ""
158
+ preview_background: str = "#272727"
159
+
160
+ def compose_body(self) -> ComposeResult:
161
+ yield Input(placeholder="type a path, then press Enter", id="path_input")
162
+ yield ListView(id="opendir")
163
+ self.preview = Static(
164
+ RichSyntax("", lexer="python", background_color=self._preview_background()),
165
+ id="script_preview",
166
+ )
167
+ yield self.preview
168
+
169
+ def on_mount(self) -> None:
170
+ self._refresh_dir(self.dir)
171
+ # browse-first dialog: arrows move the highlight (and the preview),
172
+ # Enter on a file plays it, OK confirms the current selection —
173
+ # the list takes focus after the screen's default focus pass
174
+ self.call_after_refresh(self.query_one("#opendir", ListView).focus)
175
+
176
+ def _current_choice(self) -> Path | None:
177
+ """The script the dialog would play right now: a typed path that
178
+ exists, else the currently highlighted list entry (file paths
179
+ only — directories are browsed, not played)."""
180
+ raw = self.query_one("#path_input", Input).value.strip()
181
+ if raw and Path(raw).expanduser().is_file():
182
+ return Path(raw).expanduser()
183
+ child = self.query_one("#opendir", ListView).highlighted_child
184
+ if child is not None and child.name:
185
+ chosen = Path(child.name)
186
+ if chosen.is_file():
187
+ return chosen
188
+ return None
189
+
190
+ def action_ok(self) -> None:
191
+ chosen = self._current_choice()
192
+ if chosen is None:
193
+ self.app.notify(
194
+ "Select a script file first (arrow keys highlight, Enter plays)",
195
+ severity="warning",
196
+ )
197
+ return
198
+ self.post_result(chosen)
199
+
200
+ def _update_preview(self, target: Path) -> None:
201
+ self.preview_text = self._script_text(target)
202
+ self.preview.update(
203
+ RichSyntax(
204
+ self.preview_text,
205
+ lexer="python",
206
+ background_color=self.preview_background,
207
+ )
208
+ )
209
+
210
+ def _preview_background(self) -> str:
211
+ """Hex color the preview strip paints behind the highlighted text.
212
+
213
+ The strip's CSS is `$boost` over the dialog's `$surface`; rich's
214
+ 'default' background would leave those cells to the terminal's own
215
+ background color, which is not the box color. Composite the same
216
+ colors the compositor uses, and paint the text cells with the
217
+ result — foreground (monokai) colors are untouched."""
218
+ variables = self.app.get_css_variables()
219
+ boost = Color.parse(variables["boost"])
220
+ surface = Color.parse(variables["surface"])
221
+ alpha = boost.a # Color.a is already a 0..1 float
222
+ channels = tuple(
223
+ round(channel * alpha + base * (1 - alpha))
224
+ for channel, base in zip(boost.rgb, surface.rgb)
225
+ )
226
+ hex = f"#{channels[0]:02X}{channels[1]:02X}{channels[2]:02X}"
227
+ self.preview_background = hex
228
+ return hex
229
+
230
+ def _script_text(self, target: Path) -> str:
231
+ """The script file's text (capped), or '' when target is not one."""
232
+ try:
233
+ if target.is_file() and target.suffix.lower() == SCRIPT_SUFFIX:
234
+ lines = target.read_text(
235
+ encoding="utf-8", errors="replace"
236
+ ).splitlines()
237
+ head, note = lines[: self.PREVIEW_LINES], ""
238
+ if len(lines) > self.PREVIEW_LINES:
239
+ note = f"\n# … {len(lines) - self.PREVIEW_LINES} more lines"
240
+ return "\n".join(head) + note
241
+ except OSError:
242
+ pass
243
+ return ""
244
+
245
+ @on(ListView.Highlighted, "#opendir")
246
+ def _preview_highlighted(self, event: ListView.Highlighted) -> None:
247
+ """As highlight moves through the listing, preview highlighted file."""
248
+ if event.item and event.item.name:
249
+ target = Path(event.item.name)
250
+ if target.is_file():
251
+ self._update_preview(target)
252
+
253
+ @on(Input.Changed, "#path_input")
254
+ def _preview_typed(self, event: Input.Changed) -> None:
255
+ raw = event.value.strip()
256
+ if raw:
257
+ self._update_preview(Path(raw).expanduser())
258
+
259
+
260
+ class RenameColumnModal(ModalDialog):
261
+ """Enter a new name for one column; dismisses with the name or None.
262
+
263
+ Docks in the sidebar and hugs its content: title + input + buttons."""
264
+
265
+ dialog_title = "Rename column"
266
+
267
+ DEFAULT_CSS = """
268
+ RenameColumnModal {
269
+ align: left middle;
270
+ }
271
+ RenameColumnModal > Vertical {
272
+ width: 38;
273
+ }
274
+ RenameColumnModal Input {
275
+ width: 32;
276
+ }
277
+ RenameColumnModal > Vertical > Horizontal.buttons {
278
+ height: auto;
279
+ }
280
+ """
281
+
282
+ def __init__(self, current: str) -> None:
283
+ super().__init__()
284
+ self.current = current
285
+
286
+ def compose_body(self) -> ComposeResult:
287
+ self.name_input = Input(value=self.current, id="rename_input")
288
+ yield self.name_input
289
+
290
+ def on_mount(self) -> None:
291
+ self.name_input.focus()
292
+
293
+ def action_ok(self) -> None:
294
+ raw = self.name_input.value.strip()
295
+ if not raw:
296
+ self.app.notify("Column name cannot be empty", severity="error")
297
+ return
298
+ self.post_result(raw)
299
+
300
+ @on(Input.Submitted, "#rename_input")
301
+ def _rename_input_submit(self) -> None:
302
+ self.action_ok()
303
+
304
+
305
+ class SheetPickerModal(ModalDialog):
306
+ """Pick which worksheet to load from an xlsx."""
307
+
308
+ dialog_title = "Choose worksheet"
309
+
310
+ def __init__(self, sheet_names: list[str]) -> None:
311
+ super().__init__()
312
+ self.sheet_names = sheet_names
313
+
314
+ def compose_body(self) -> ComposeResult:
315
+ # Select.BLANK is just `False` in this Textual version; never pass it
316
+ # as `value` — omit `value` instead (default is the true blank sentinel)
317
+ self.sheet_choice = Select(
318
+ [(name, name) for name in self.sheet_names],
319
+ allow_blank=False,
320
+ id="sheet_pick",
321
+ )
322
+ yield self.sheet_choice
323
+
324
+ def action_ok(self) -> None:
325
+ self.post_result(self.sheet_choice.value)
326
+
327
+
328
+ class OpParamsModal(ModalDialog):
329
+ """Collect parameters for an Operation; dismisses with the params dict.
330
+
331
+ Docks over the sidebar (like the op chooser), so the preview table in the
332
+ main pane keeps showing data rows while parameters are entered."""
333
+
334
+ dialog_title = "Parameters"
335
+
336
+ DEFAULT_CSS = """
337
+ OpParamsModal {
338
+ align: left middle;
339
+ }
340
+ OpParamsModal > Vertical {
341
+ width: 38;
342
+ }
343
+ OpParamsModal SelectionList,
344
+ OpParamsModal Select,
345
+ OpParamsModal Input {
346
+ width: 32;
347
+ }
348
+ /* a long column list must not push the OK/Cancel buttons and later
349
+ * fields out of the dialog: cap it and let it scroll internally */
350
+ OpParamsModal SelectionList {
351
+ max-height: 10;
352
+ }
353
+ OpParamsModal Label.help {
354
+ width: 32;
355
+ text-wrap: wrap;
356
+ }
357
+ """
358
+
359
+ def __init__(self, op: Operation) -> None:
360
+ super().__init__()
361
+ self.op = op
362
+ self.dialog_title = op.title
363
+
364
+ def compose_body(self) -> ComposeResult:
365
+ self.widgets: dict[str, object] = {}
366
+ candidates = self._date_candidates() if self.op.key == "date_normalize" else []
367
+ for spec in self.op.params:
368
+ if spec.kind == "column_multi":
369
+ sel = SelectionList(
370
+ *[(name, name, False) for name in self._available_columns()],
371
+ id="param_columns",
372
+ )
373
+ self.widgets[spec.name] = sel
374
+ yield sel
375
+ elif spec.kind == "column":
376
+ col_names = self._available_columns()
377
+ preselect = (
378
+ candidates[0] if candidates and candidates[0] in col_names else None
379
+ )
380
+ sel = Select(
381
+ [(name, name) for name in col_names],
382
+ allow_blank=True,
383
+ id="param_column",
384
+ )
385
+ self.preselect_column = preselect
386
+ self.widgets[spec.name] = sel
387
+ yield sel
388
+ elif spec.kind == "choice":
389
+ sel = Select(
390
+ [(c, c) for c in spec.choices or ()],
391
+ allow_blank=False,
392
+ value=spec.default,
393
+ id="param_choice",
394
+ )
395
+ self.widgets[spec.name] = sel
396
+ yield sel
397
+ elif spec.kind == "number":
398
+ inp = Input(
399
+ str(spec.default or ""),
400
+ type="integer",
401
+ id=f"param_number_{spec.name}",
402
+ classes="param_input",
403
+ )
404
+ self.widgets[spec.name] = inp
405
+ yield inp
406
+ elif spec.kind == "bool":
407
+ chk = Checkbox(spec.title, spec.default is True)
408
+ self.widgets[spec.name] = chk
409
+ yield chk
410
+ else:
411
+ default = spec.default if isinstance(spec.default, str) else ""
412
+ # a whitespace-only default (the separator's single space)
413
+ # looks empty anyway; start blank so the placeholder acts
414
+ # as visible shadow text
415
+ value = "" if not default.strip() else default
416
+ inp = Input(
417
+ value=value,
418
+ placeholder=spec.title,
419
+ id=f"param_text_{spec.name}",
420
+ classes="param_input",
421
+ )
422
+ self.widgets[spec.name] = inp
423
+ yield inp
424
+ if spec.help:
425
+ yield Label(spec.help, classes="help")
426
+
427
+ def on_mount(self) -> None:
428
+ # Select options are usable only after mount; apply any preselect here
429
+ preselect = getattr(self, "preselect_column", None)
430
+ if preselect is not None:
431
+ self.widgets["column"].value = preselect
432
+
433
+ def _available_columns(self) -> list[str]:
434
+ return list(getattr(self.app, "current_columns", []) or [])
435
+
436
+ def _date_candidates(self) -> list[str]:
437
+ analyzed = getattr(self.app, "analyzed", {}) or {}
438
+ return [name for name, info in analyzed.items() if info.date_candidate]
439
+
440
+ def collect_params(self) -> dict:
441
+ params: dict = {}
442
+ for spec in self.op.params:
443
+ widget = self.widgets[spec.name]
444
+ if spec.kind == "column_multi":
445
+ params[spec.name] = list(widget.selected)
446
+ elif spec.kind == "column":
447
+ params[spec.name] = (
448
+ None if widget.value is Select.NULL else widget.value
449
+ )
450
+ elif spec.kind == "number":
451
+ raw = widget.value.strip()
452
+ params[spec.name] = int(raw) if raw else None
453
+ elif spec.kind == "bool":
454
+ params[spec.name] = widget.value is True
455
+ else:
456
+ params[spec.name] = widget.value
457
+ return params
458
+
459
+ def action_ok(self) -> None:
460
+ pipeline = getattr(self.app, "pipeline", None)
461
+ if pipeline is None:
462
+ self.app.notify("No file loaded", severity="error")
463
+ return
464
+ params = self.collect_params()
465
+ problem = pipeline.validate(self.op, params)
466
+ if problem:
467
+ self.app.notify(problem, severity="error")
468
+ return
469
+ self.post_result(params)
470
+
471
+ @on(Input.Submitted, ".param_input")
472
+ def _input_submit_ok(self) -> None:
473
+ self.action_ok()
474
+
475
+
476
+ class SaveModal(ModalDialog):
477
+ """Path + format for writing the normalized table."""
478
+
479
+ dialog_title = "Save as"
480
+
481
+ def __init__(self, suggested: Path | None) -> None:
482
+ super().__init__()
483
+ self.suggested = suggested
484
+
485
+ def compose_body(self) -> ComposeResult:
486
+ path_stub = self.suggested or Path("normalized.csv")
487
+ self.path_input = Input(
488
+ placeholder=str(path_stub),
489
+ value=str(path_stub),
490
+ id="save_input",
491
+ )
492
+ self.overwrite = Checkbox(
493
+ "Allow overwrite if file exists", False, id="allow_overwrite"
494
+ )
495
+ self.save_script = Checkbox(
496
+ "Save sequence of operations?", False, id="save_script"
497
+ )
498
+ extensions = " ".join(sorted(WRITE_SUFFIXES))
499
+ yield self.path_input
500
+ yield Static(f"Extensions: {extensions}", classes="help")
501
+ yield self.overwrite
502
+ yield self.save_script
503
+
504
+ def action_ok(self) -> None:
505
+ raw = self.path_input.value.strip()
506
+ if not raw:
507
+ self.app.notify("Type a file name or path", severity="error")
508
+ return
509
+ target = Path(raw).expanduser()
510
+ fmt = WRITE_SUFFIXES.get(target.suffix.lower())
511
+ if fmt is None:
512
+ self.app.notify(
513
+ "Unsupported extension: use one of "
514
+ + ", ".join(sorted(WRITE_SUFFIXES)),
515
+ severity="error",
516
+ )
517
+ return
518
+ if target.exists() and not self.overwrite.value:
519
+ self.app.notify(
520
+ "File exists: tick the overwrite box or pick another path",
521
+ severity="warning",
522
+ )
523
+ return
524
+ self.post_result((target, fmt, self.save_script.value is True))
525
+
526
+ @on(Input.Submitted, "#save_input")
527
+ def _save_input_submit(self) -> None:
528
+ self.action_ok()
529
+
530
+
531
+ class ScriptNameModal(ModalDialog):
532
+ """Name the '.ntd' script file when saving a sequence of operations.
533
+
534
+ The default carries the .ntd extension; typing a different one saves
535
+ under that extension instead."""
536
+
537
+ dialog_title = "Save script"
538
+
539
+ def __init__(self, suggested: Path) -> None:
540
+ super().__init__()
541
+ self.suggested = suggested
542
+
543
+ def compose_body(self) -> ComposeResult:
544
+ self.path_input = Input(
545
+ placeholder=str(self.suggested),
546
+ value=str(self.suggested),
547
+ id="script_input",
548
+ )
549
+ yield self.path_input
550
+ yield Static(
551
+ f"Default extension: {SCRIPT_SUFFIX} — type another to override",
552
+ classes="help",
553
+ )
554
+
555
+ def action_ok(self) -> None:
556
+ raw = self.path_input.value.strip()
557
+ if not raw:
558
+ self.app.notify("Type a script file name or path", severity="error")
559
+ return
560
+ target = Path(raw).expanduser()
561
+ if target.exists():
562
+ self.app.notify("Script file exists: pick another name", severity="warning")
563
+ return
564
+ self.post_result(target)
565
+
566
+ @on(Input.Submitted, "#script_input")
567
+ def _script_input_submit(self) -> None:
568
+ self.action_ok()