dtypeflow 1.0.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.
Files changed (43) hide show
  1. dtypeflow/__init__.py +5 -0
  2. dtypeflow/core/__init__.py +1 -0
  3. dtypeflow/core/candidate/__init__.py +40 -0
  4. dtypeflow/core/candidate/base.py +37 -0
  5. dtypeflow/core/candidate/factory.py +258 -0
  6. dtypeflow/core/candidate/primitive.py +79 -0
  7. dtypeflow/core/dataset.py +46 -0
  8. dtypeflow/core/dtype/__init__.py +70 -0
  9. dtypeflow/core/dtype/base.py +14 -0
  10. dtypeflow/core/dtype/hierachy.md +51 -0
  11. dtypeflow/core/dtype/nested/__init__.py +5 -0
  12. dtypeflow/core/dtype/nested/dictionary.py +14 -0
  13. dtypeflow/core/dtype/primitive/__init__.py +39 -0
  14. dtypeflow/core/dtype/primitive/base.py +6 -0
  15. dtypeflow/core/dtype/primitive/boolean.py +20 -0
  16. dtypeflow/core/dtype/primitive/float.py +40 -0
  17. dtypeflow/core/dtype/primitive/integer/__init__.py +31 -0
  18. dtypeflow/core/dtype/primitive/integer/base.py +17 -0
  19. dtypeflow/core/dtype/primitive/integer/signed.py +37 -0
  20. dtypeflow/core/dtype/primitive/integer/unsigned.py +37 -0
  21. dtypeflow/core/dtype/primitive/string.py +14 -0
  22. dtypeflow/core/override/__init__.py +41 -0
  23. dtypeflow/core/override/base.py +22 -0
  24. dtypeflow/core/override/primitive.py +158 -0
  25. dtypeflow/core/profile/__init__.py +39 -0
  26. dtypeflow/core/profile/base.py +50 -0
  27. dtypeflow/core/profile/primitive.py +166 -0
  28. dtypeflow/core/profiler.py +169 -0
  29. dtypeflow/core/schema.py +33 -0
  30. dtypeflow/core/table.py +29 -0
  31. dtypeflow/py.typed +0 -0
  32. dtypeflow/tui/__init__.py +17 -0
  33. dtypeflow/tui/app.py +747 -0
  34. dtypeflow/tui/app.tcss +220 -0
  35. dtypeflow/tui/compatibility.py +38 -0
  36. dtypeflow/tui/dto.py +44 -0
  37. dtypeflow/tui/service.py +213 -0
  38. dtypeflow/utils.py +32 -0
  39. dtypeflow-1.0.0.dist-info/METADATA +93 -0
  40. dtypeflow-1.0.0.dist-info/RECORD +43 -0
  41. dtypeflow-1.0.0.dist-info/WHEEL +4 -0
  42. dtypeflow-1.0.0.dist-info/entry_points.txt +3 -0
  43. dtypeflow-1.0.0.dist-info/licenses/LICENSE +21 -0
dtypeflow/tui/app.py ADDED
@@ -0,0 +1,747 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
6
+
7
+ from textual import on, work
8
+ from textual.app import App, ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal, Vertical, VerticalGroup, VerticalScroll
11
+ from textual.css.query import NoMatches
12
+ from textual.screen import ModalScreen, Screen
13
+ from textual.widgets import (
14
+ Button,
15
+ Checkbox,
16
+ DataTable,
17
+ DirectoryTree,
18
+ Footer,
19
+ Header,
20
+ Input,
21
+ Label,
22
+ OptionList,
23
+ RadioButton,
24
+ RadioSet,
25
+ Static,
26
+ Tab,
27
+ Tabs,
28
+ )
29
+ from textual.widgets.option_list import Option
30
+
31
+ from .compatibility import GROUP_ORDER
32
+ from .dto import ColumnReport, ColumnSelection, DtypeOption, LoadedDataset
33
+ from .service import DtypeflowService
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Iterable
37
+
38
+ from textual.widget import Widget
39
+ _GROUP_ICONS: dict[str, str] = {"Safe": "✓", "Too small": "↓", "Discouraged": "!"}
40
+ _TYPE_COL_WIDTH = 14
41
+ _STAR_COLOR = "gold"
42
+ _TYPE_COLOR = "cyan"
43
+ _NUMBER_COLOR = "#ffd166"
44
+ _NUMBER_PATTERN = re.compile("-?\\d[\\d,]*(?:\\.\\d+)?(?:E[+-]\\d+)?")
45
+
46
+
47
+ def _colorize_type(label: str) -> str:
48
+ return f"[{_TYPE_COLOR}]{label}[/{_TYPE_COLOR}]"
49
+
50
+
51
+ def _colorize_description(name: str, description: str) -> str:
52
+ if name == "Boolean":
53
+ colored = description.replace(
54
+ "True", f"[{_NUMBER_COLOR}]True[/{_NUMBER_COLOR}]",
55
+ )
56
+ return colored.replace("False", f"[{_NUMBER_COLOR}]False[/{_NUMBER_COLOR}]")
57
+ return _NUMBER_PATTERN.sub(
58
+ lambda match: f"[{_NUMBER_COLOR}]{match.group()}[/{_NUMBER_COLOR}]", description,
59
+ )
60
+
61
+
62
+ _FALSY_TOKENS = frozenset({"0", "f", "false", "n", "no"})
63
+ _TRUTHY_TOKENS = frozenset({"1", "t", "true", "y", "yes"})
64
+ _TWO_VALUES = 2
65
+
66
+
67
+ def _grouped_options(options: tuple[DtypeOption, ...]) -> dict[str, list[DtypeOption]]:
68
+ grouped: dict[str, list[DtypeOption]] = {}
69
+ for option in options:
70
+ grouped.setdefault(option.group, []).append(option)
71
+ return {group: grouped[group] for group in GROUP_ORDER if group in grouped}
72
+
73
+
74
+ def _stats_text(stats: tuple[tuple[str, str], ...]) -> str:
75
+ return "\n".join(
76
+
77
+ f"{label}: [{_NUMBER_COLOR}]{value}[/{_NUMBER_COLOR}]"
78
+ for (label, value) in stats
79
+
80
+ )
81
+
82
+
83
+ def _dtype_row_text(option: DtypeOption) -> str:
84
+ padded_label = f"{option.label:<{_TYPE_COL_WIDTH}}"
85
+ colored_label = f"[{_TYPE_COLOR}]{padded_label}[/{_TYPE_COLOR}]"
86
+ description = _colorize_description(option.name, option.description)
87
+ return f"{colored_label}{description}"
88
+
89
+
90
+ def _status_classes(*, ok: bool | None) -> str:
91
+ if ok is None:
92
+ return "status"
93
+ return "status -ok" if ok else "status -error"
94
+
95
+
96
+ def _default_true_value(values: tuple[str, str]) -> str:
97
+ (first, second) = values
98
+ if (
99
+ first.strip().lower() in _FALSY_TOKENS
100
+ or second.strip().lower() in _TRUTHY_TOKENS
101
+ ):
102
+ return second
103
+ if (
104
+ second.strip().lower() in _FALSY_TOKENS
105
+ or first.strip().lower() in _TRUTHY_TOKENS
106
+ ):
107
+ return first
108
+ return second
109
+
110
+
111
+ class CSVDirectoryTree(DirectoryTree):
112
+ def __init__(
113
+ self, path: str | Path, *, suffixes: tuple[str, ...], **kwargs: Any,
114
+ ) -> None:
115
+ super().__init__(path, **kwargs)
116
+ self._suffixes = suffixes
117
+
118
+ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
119
+ return [
120
+ path
121
+ for path in paths
122
+ if path.is_dir() or path.suffix.lower() in self._suffixes
123
+ ]
124
+
125
+
126
+ class BrowseModal(ModalScreen[str | None]):
127
+ BINDINGS: ClassVar[list[Binding]] = [Binding("escape", "cancel", "Cancel")]
128
+
129
+ def __init__(
130
+ self,
131
+ *,
132
+ mode: Literal["open", "save"],
133
+ suffixes: tuple[str, ...],
134
+ title: str,
135
+ start_dir: Path | None = None,
136
+ default_filename: str = "",
137
+ ) -> None:
138
+ super().__init__()
139
+ self._mode = mode
140
+ self._suffixes = suffixes
141
+ self._title = title
142
+ self._current_dir = start_dir or Path.cwd()
143
+ self._default_filename = default_filename
144
+
145
+ def compose(self) -> ComposeResult:
146
+ with Vertical(classes="browse-dialog"):
147
+ yield Label(self._title, classes="app-subtitle")
148
+ yield CSVDirectoryTree(
149
+ self._current_dir, suffixes=self._suffixes, id="browse-tree",
150
+ )
151
+ yield Input(
152
+ value=self._default_filename,
153
+ placeholder="File name or full path",
154
+ id="browse-input",
155
+ )
156
+ with Horizontal(classes="browse-buttons"):
157
+ yield Button("Cancel", id="browse-cancel")
158
+ yield Button("Select", id="browse-select", variant="primary")
159
+
160
+ def on_directory_tree_directory_selected(
161
+ self, event: DirectoryTree.DirectorySelected,
162
+ ) -> None:
163
+ self._current_dir = event.path
164
+
165
+ def on_directory_tree_file_selected(
166
+ self, event: DirectoryTree.FileSelected,
167
+ ) -> None:
168
+ if self._mode == "open":
169
+ self.dismiss(str(event.path))
170
+ return
171
+ self._current_dir = event.path.parent
172
+ self.query_one("#browse-input", Input).value = event.path.name
173
+
174
+ @on(Button.Pressed, "#browse-cancel")
175
+ def _cancel(self) -> None:
176
+ self.dismiss(None)
177
+
178
+ @on(Button.Pressed, "#browse-select")
179
+ def _select(self) -> None:
180
+ entered = self.query_one("#browse-input", Input).value.strip()
181
+ if not entered:
182
+ self.notify("Enter a file name.", severity="warning")
183
+ return
184
+ entered_path = Path(entered)
185
+ destination = (
186
+ entered_path
187
+ if entered_path.is_absolute()
188
+ else self._current_dir / entered_path
189
+ )
190
+ self.dismiss(str(destination))
191
+
192
+ def action_cancel(self) -> None:
193
+ self.dismiss(None)
194
+
195
+
196
+ class CategoryOrderPicker(Vertical):
197
+ def __init__(self, categories: tuple[str, ...], **kwargs: Any) -> None:
198
+ super().__init__(**kwargs)
199
+ self._order: list[str] = list(categories)
200
+
201
+ def compose(self) -> ComposeResult:
202
+ yield OptionList(*self._order, id="order-list")
203
+ with Horizontal(classes="reorder-buttons"):
204
+ yield Button("▲ Move up", id="move-up")
205
+ yield Button("▼ Move down", id="move-down")
206
+
207
+ @property
208
+ def order(self) -> tuple[str, ...]:
209
+ return tuple(self._order)
210
+
211
+ @on(Button.Pressed, "#move-up")
212
+ def _move_up(self) -> None:
213
+ self._move(-1)
214
+
215
+ @on(Button.Pressed, "#move-down")
216
+ def _move_down(self) -> None:
217
+ self._move(1)
218
+
219
+ def _move(self, delta: int) -> None:
220
+ option_list = self.query_one("#order-list", OptionList)
221
+ index = option_list.highlighted
222
+ if index is None:
223
+ return
224
+ new_index = index + delta
225
+ if not 0 <= new_index < len(self._order):
226
+ return
227
+ (self._order[index], self._order[new_index]) = (
228
+ self._order[new_index],
229
+ self._order[index],
230
+ )
231
+ option_list.clear_options()
232
+ option_list.add_options(self._order)
233
+ option_list.highlighted = new_index
234
+
235
+
236
+ class ColumnPanel(Vertical):
237
+ def __init__(
238
+ self,
239
+ column: ColumnReport,
240
+ service: DtypeflowService,
241
+ loaded: LoadedDataset,
242
+ **kwargs: Any,
243
+ ) -> None:
244
+ super().__init__(**kwargs)
245
+ self.column = column
246
+ self._service = service
247
+ self._loaded = loaded
248
+ self._grouped = _grouped_options(column.options)
249
+ self._groups = list(self._grouped)
250
+ self._tab_ids = {group: f"group-{i}" for (i, group) in enumerate(self._groups)}
251
+ self._group_by_tab_id = {
252
+ tab_id: group for (group, tab_id) in self._tab_ids.items()
253
+ }
254
+ self._recommended: DtypeOption = column.options[0]
255
+ self._chosen: DtypeOption = self._recommended
256
+ self._active_group: str = self._recommended.group
257
+ self._unique_values: tuple[str, ...] | None = None
258
+
259
+ def compose(self) -> ComposeResult:
260
+ recommended_label = _colorize_type(self._recommended.label)
261
+ recommended_description = _colorize_description(
262
+ self._recommended.name, self._recommended.description,
263
+ )
264
+ yield Static(self.column.name, classes="column-title")
265
+ yield Static(
266
+ f"[{_STAR_COLOR}]Recommended:[/{_STAR_COLOR}] {recommended_label} — {recommended_description}",
267
+ classes="column-recommended",
268
+ )
269
+ yield Static(_stats_text(self.column.stats), classes="column-stats")
270
+ yield Tabs(*self._category_tabs(), id="dtype-tabs")
271
+ yield OptionList(*self._option_rows(self._active_group), id="dtype-options")
272
+ yield Static(
273
+ _colorize_description(self._chosen.name, self._chosen.description),
274
+ classes="dtype-description",
275
+ )
276
+ yield VerticalGroup(id="extra-container")
277
+
278
+ async def on_mount(self) -> None:
279
+ tabs = self.query_one("#dtype-tabs", Tabs)
280
+ tabs.active = self._tab_ids[self._active_group]
281
+ option_list = self.query_one("#dtype-options", OptionList)
282
+ option_list.highlighted = option_list.get_option_index(self._recommended.name)
283
+ await self._rebuild_extra()
284
+
285
+ def _category_tabs(self) -> list[Tab]:
286
+ return [
287
+ Tab(
288
+ f"{_GROUP_ICONS.get(group, '')} {group}".strip(),
289
+ id=self._tab_ids[group],
290
+ )
291
+ for group in self._groups
292
+ ]
293
+
294
+ @on(Tabs.TabActivated, "#dtype-tabs")
295
+ async def _tab_activated(self, event: Tabs.TabActivated) -> None:
296
+ group = self._group_by_tab_id[event.tab.id]
297
+ if group == self._active_group:
298
+ return
299
+ self._active_group = group
300
+ option_list = self.query_one("#dtype-options", OptionList)
301
+ option_list.clear_options()
302
+ option_list.add_options(self._option_rows(group))
303
+ option_list.highlighted = 0
304
+ await self._select_option(self._grouped[group][0])
305
+
306
+ def _option_rows(self, group: str) -> list[Option]:
307
+ return [
308
+ Option(_dtype_row_text(option), id=option.name)
309
+ for option in self._grouped[group]
310
+ ]
311
+
312
+ def _option_by_name(self, name: str) -> DtypeOption:
313
+ for options in self._grouped.values():
314
+ for option in options:
315
+ if option.name == name:
316
+ return option
317
+ msg = f"Unknown dtype option {name!r} for column {self.column.name!r}."
318
+ raise KeyError(msg)
319
+
320
+ @on(OptionList.OptionSelected, "#dtype-options")
321
+ async def _dtype_selected(self, event: OptionList.OptionSelected) -> None:
322
+ await self._select_option(self._option_by_name(cast("str", event.option.id)))
323
+
324
+ async def _select_option(self, option: DtypeOption) -> None:
325
+ self._chosen = option
326
+ description = _colorize_description(option.name, option.description)
327
+ self.query_one(".dtype-description", Static).update(description)
328
+ await self._rebuild_extra()
329
+
330
+ async def _rebuild_extra(self) -> None:
331
+ container = self.query_one("#extra-container", VerticalGroup)
332
+ await container.remove_children()
333
+ if self._chosen.name == "Boolean":
334
+ await container.mount(*self._boolean_widgets())
335
+ elif self._chosen.name == "Dictionary":
336
+ await container.mount(*self._dictionary_widgets())
337
+
338
+ def _values(self) -> tuple[str, ...]:
339
+ if self._unique_values is None:
340
+ self._unique_values = self._service.unique_values(
341
+ self._loaded, self.column.name,
342
+ )
343
+ return self._unique_values
344
+
345
+ def _boolean_widgets(self) -> list[Widget]:
346
+ values = self._values()
347
+ if len(values) >= _TWO_VALUES:
348
+ (true_value, false_value) = self._boolean_order(values)
349
+ return [
350
+ Label("Which value means True?"),
351
+ RadioSet(
352
+ RadioButton(
353
+ f"{true_value!r} means True / {false_value!r} means False",
354
+ value=True,
355
+ ),
356
+ RadioButton(
357
+ f"{false_value!r} means True / {true_value!r} means False",
358
+ value=False,
359
+ ),
360
+ id="bool-radio",
361
+ ),
362
+ ]
363
+ if len(values) == 1:
364
+ return [
365
+ Static(
366
+ f"Only one observed value: {values[0]!r}", classes="boolean-caption",
367
+ ),
368
+ Checkbox(f"{values[0]!r} means True", id="bool-single-toggle"),
369
+ Input(
370
+ value="OTHER",
371
+ placeholder="Label for the value that never appears",
372
+ id="bool-other-label",
373
+ ),
374
+ ]
375
+ return [
376
+ Static("No observed values to map to Boolean.", classes="status -error"),
377
+ ]
378
+
379
+ def _boolean_order(self, values: tuple[str, ...]) -> tuple[str, str]:
380
+ true_value = _default_true_value((values[0], values[1]))
381
+ false_value = values[1] if true_value == values[0] else values[0]
382
+ return (true_value, false_value)
383
+
384
+ def _dictionary_widgets(self) -> list[Widget]:
385
+ values = self._values()
386
+ picker = CategoryOrderPicker(values, id="dict-order", classes="dictionary-box")
387
+ picker.display = False
388
+ return [Checkbox("Ordered categories", id="dict-ordered"), picker]
389
+
390
+ @on(Checkbox.Changed, "#dict-ordered")
391
+ def _ordered_changed(self, event: Checkbox.Changed) -> None:
392
+ try:
393
+ picker = self.query_one("#dict-order", CategoryOrderPicker)
394
+ except NoMatches:
395
+ return
396
+ picker.display = event.value
397
+
398
+ def is_valid(self) -> bool:
399
+ if self._chosen.name != "Boolean":
400
+ return True
401
+ values = self._values()
402
+ if len(values) >= _TWO_VALUES:
403
+ return True
404
+ if len(values) == 1:
405
+ try:
406
+ other = self.query_one("#bool-other-label", Input).value.strip()
407
+ except NoMatches:
408
+ return False
409
+ return bool(other) and other.lower() != values[0].strip().lower()
410
+ return False
411
+
412
+ def get_selection(self) -> ColumnSelection:
413
+ if self._chosen.name == "Boolean":
414
+ return self._boolean_selection()
415
+ if self._chosen.name == "Dictionary":
416
+ return self._dictionary_selection()
417
+ return ColumnSelection(dtype_name=self._chosen.name)
418
+
419
+ def _boolean_selection(self) -> ColumnSelection:
420
+ values = self._values()
421
+ if len(values) >= _TWO_VALUES:
422
+ radio = self.query_one("#bool-radio", RadioSet)
423
+ (true_default, false_default) = self._boolean_order(values)
424
+ true_value = true_default if radio.pressed_index == 0 else false_default
425
+ false_value = false_default if radio.pressed_index == 0 else true_default
426
+ return ColumnSelection(
427
+ dtype_name="Boolean", true_value=true_value, false_value=false_value,
428
+ )
429
+ if len(values) == 1:
430
+ is_true = self.query_one("#bool-single-toggle", Checkbox).value
431
+ other_input = self.query_one("#bool-other-label", Input).value.strip()
432
+ other_label = other_input or "OTHER"
433
+ true_value = values[0] if is_true else other_label
434
+ false_value = other_label if is_true else values[0]
435
+ return ColumnSelection(
436
+ dtype_name="Boolean", true_value=true_value, false_value=false_value,
437
+ )
438
+ return ColumnSelection(dtype_name="Boolean")
439
+
440
+ def _dictionary_selection(self) -> ColumnSelection:
441
+ ordered = self.query_one("#dict-ordered", Checkbox).value
442
+ categories = (
443
+ self.query_one("#dict-order", CategoryOrderPicker).order
444
+ if ordered
445
+ else self._values()
446
+ )
447
+ return ColumnSelection(
448
+ dtype_name="Dictionary", ordered=ordered, categories=categories,
449
+ )
450
+
451
+
452
+ class PickFileScreen(Screen[None]):
453
+ def __init__(self, initial_path: str | None = None) -> None:
454
+ super().__init__()
455
+ self._initial_path = initial_path
456
+
457
+ def compose(self) -> ComposeResult:
458
+ yield Header()
459
+ with VerticalScroll(id="pick-file-screen"), Vertical(classes="panel hero"):
460
+ with Horizontal(classes="field-row"):
461
+ yield Input(
462
+ placeholder="Path to a CSV file",
463
+ id="csv-path",
464
+ value=self._initial_path or "",
465
+ )
466
+ yield Button("Browse…", id="browse-csv")
467
+ yield Button(
468
+ "Load dataset",
469
+ id="load-btn",
470
+ variant="primary",
471
+ classes="primary-action",
472
+ )
473
+ yield Static("", id="pick-status", classes="status")
474
+ yield Footer()
475
+
476
+ def on_mount(self) -> None:
477
+ if self._initial_path:
478
+ self._start_load(self._initial_path)
479
+
480
+ @on(Button.Pressed, "#browse-csv")
481
+ def _browse(self) -> None:
482
+
483
+ def _picked(path: str | None) -> None:
484
+ if path:
485
+ self.query_one("#csv-path", Input).value = path
486
+
487
+ self.app.push_screen(
488
+ BrowseModal(mode="open", suffixes=(".csv",), title="Pick a CSV file"),
489
+ _picked,
490
+ )
491
+
492
+ @on(Button.Pressed, "#load-btn")
493
+ def _load_pressed(self) -> None:
494
+ path = self.query_one("#csv-path", Input).value.strip()
495
+ if not path:
496
+ self._set_status("Enter or browse for a CSV path.", ok=False)
497
+ return
498
+ self._start_load(path)
499
+
500
+ def _start_load(self, path: str) -> None:
501
+ self._set_status("Reading and profiling…", ok=None)
502
+ self.query_one("#load-btn", Button).disabled = True
503
+ self._load(path)
504
+
505
+ @work(thread=True)
506
+ def _load(self, path: str) -> None:
507
+ app = cast("DtypeflowApp", self.app)
508
+ try:
509
+ loaded = app.service.load_dataset(path)
510
+ except Exception as exc:
511
+ self.app.call_from_thread(self._load_failed, str(exc))
512
+ return
513
+ self.app.call_from_thread(self._load_succeeded, loaded)
514
+
515
+ def _load_succeeded(self, loaded: LoadedDataset) -> None:
516
+ app = cast("DtypeflowApp", self.app)
517
+ app.loaded = loaded
518
+ app.selections = None
519
+ self.query_one("#load-btn", Button).disabled = False
520
+ self._set_status("", ok=None)
521
+ self.app.push_screen(ConfigureScreen())
522
+
523
+ def _load_failed(self, message: str) -> None:
524
+ self.query_one("#load-btn", Button).disabled = False
525
+ self._set_status(f"Could not load file: {message}", ok=False)
526
+
527
+ def _set_status(self, message: str, *, ok: bool | None) -> None:
528
+ status = self.query_one("#pick-status", Static)
529
+ status.set_classes(_status_classes(ok=ok))
530
+ status.update(message)
531
+
532
+
533
+ class ConfigureScreen(Screen[None]):
534
+ BINDINGS: ClassVar[list[Binding]] = [
535
+ Binding("escape", "change_file", "Change file"),
536
+ ]
537
+
538
+ def __init__(self) -> None:
539
+ super().__init__()
540
+ self._index = 0
541
+
542
+ def compose(self) -> ComposeResult:
543
+ app = cast("DtypeflowApp", self.app)
544
+ loaded = app.loaded
545
+ assert loaded is not None
546
+ yield Header()
547
+ if not app.file_locked:
548
+ with Horizontal(id="configure-header"):
549
+ yield Button("← Change file", id="change-file")
550
+ with VerticalScroll(id="configure-columns"):
551
+ for index, column in enumerate(loaded.report.columns):
552
+ yield ColumnPanel(column, app.service, loaded, id=f"panel-{index}")
553
+ with Vertical(id="configure-footer"):
554
+ yield Static("", id="configure-status", classes="status")
555
+ with Horizontal(id="configure-footer-nav"):
556
+ yield Button("← Back", id="back-btn")
557
+ yield Static("", id="configure-counter", classes="footer-counter")
558
+ yield Button("Next", id="next-btn", variant="primary")
559
+ yield Footer()
560
+
561
+ def on_mount(self) -> None:
562
+ self._show_current()
563
+
564
+ def _panels(self) -> list[ColumnPanel]:
565
+ return list(self.query(ColumnPanel))
566
+
567
+ def _show_current(self) -> None:
568
+ panels = self._panels()
569
+ for index, panel in enumerate(panels):
570
+ panel.display = index == self._index
571
+ self.query_one("#configure-counter", Static).update(
572
+ f"{self._index + 1} of {len(panels)}",
573
+ )
574
+ self.query_one("#configure-status", Static).update("")
575
+ self.query_one("#back-btn", Button).disabled = self._index == 0
576
+ is_last = self._index == len(panels) - 1
577
+ self.query_one("#next-btn", Button).label = "Finish" if is_last else "Next"
578
+
579
+ @on(Button.Pressed, "#change-file")
580
+ def _change_file(self) -> None:
581
+ self.action_change_file()
582
+
583
+ def action_change_file(self) -> None:
584
+ app = cast("DtypeflowApp", self.app)
585
+ if app.file_locked:
586
+ return
587
+ app.loaded = None
588
+ self.app.pop_screen()
589
+
590
+ @on(Button.Pressed, "#back-btn")
591
+ def _back(self) -> None:
592
+ if self._index > 0:
593
+ self._index -= 1
594
+ self._show_current()
595
+
596
+ @on(Button.Pressed, "#next-btn")
597
+ def _next(self) -> None:
598
+ panels = self._panels()
599
+ current = panels[self._index]
600
+ if not current.is_valid():
601
+ self.query_one("#configure-status", Static).update(
602
+ f"Finish configuring {current.column.name!r} before continuing.",
603
+ )
604
+ return
605
+ if self._index == len(panels) - 1:
606
+ app = cast("DtypeflowApp", self.app)
607
+ app.selections = {
608
+ panel.column.name: panel.get_selection() for panel in panels
609
+ }
610
+ self.app.push_screen(ExportScreen())
611
+ return
612
+ self._index += 1
613
+ self._show_current()
614
+
615
+
616
+ class ExportScreen(Screen[None]):
617
+ BINDINGS: ClassVar[list[Binding]] = [Binding("escape", "back", "Back")]
618
+
619
+ def compose(self) -> ComposeResult:
620
+ app = cast("DtypeflowApp", self.app)
621
+ loaded = app.loaded
622
+ assert loaded is not None
623
+ default_destination = str(Path(loaded.report.source).with_suffix(".parquet"))
624
+ yield Header()
625
+ with VerticalScroll():
626
+ with Vertical(classes="panel hero"):
627
+ yield Label("Save as Parquet", classes="app-title")
628
+ with Horizontal(classes="field-row"):
629
+ yield Input(
630
+ value=default_destination,
631
+ placeholder="Output .parquet path",
632
+ id="dest-path",
633
+ )
634
+ yield Button("Choose destination…", id="browse-dest")
635
+ yield Button(
636
+ "Store", id="store-btn", variant="primary", classes="primary-action",
637
+ )
638
+ yield Static("", id="export-status", classes="status")
639
+ yield DataTable(id="export-result")
640
+ with Horizontal(classes="browse-buttons"):
641
+ yield Button("← Back", id="back-btn")
642
+ yield Button("Quit", id="quit-btn", variant="primary")
643
+ yield Footer()
644
+
645
+ def on_mount(self) -> None:
646
+ table = self.query_one("#export-result", DataTable)
647
+ table.add_columns("Column", "Arrow type")
648
+ table.display = False
649
+ self.query_one("#quit-btn", Button).display = False
650
+
651
+ @on(Button.Pressed, "#browse-dest")
652
+ def _browse(self) -> None:
653
+ app = cast("DtypeflowApp", self.app)
654
+ loaded = app.loaded
655
+ assert loaded is not None
656
+ default_name = Path(loaded.report.source).with_suffix(".parquet").name
657
+
658
+ def _picked(path: str | None) -> None:
659
+ if path:
660
+ self.query_one("#dest-path", Input).value = path
661
+
662
+ self.app.push_screen(
663
+ BrowseModal(
664
+ mode="save",
665
+ suffixes=(".parquet",),
666
+ title="Choose a destination",
667
+ default_filename=default_name,
668
+ ),
669
+ _picked,
670
+ )
671
+
672
+ @on(Button.Pressed, "#store-btn")
673
+ def _store_pressed(self) -> None:
674
+ destination = self.query_one("#dest-path", Input).value.strip()
675
+ if not destination:
676
+ self._set_status("Enter or choose a destination path.", ok=False)
677
+ return
678
+ self._set_status("Storing…", ok=None)
679
+ self.query_one("#store-btn", Button).disabled = True
680
+ self._store(destination)
681
+
682
+ @work(thread=True)
683
+ def _store(self, destination: str) -> None:
684
+ app = cast("DtypeflowApp", self.app)
685
+ loaded = app.loaded
686
+ selections = app.selections
687
+ assert loaded is not None
688
+ assert selections is not None
689
+ try:
690
+ written_schema = app.service.export(loaded, destination, selections)
691
+ except Exception as exc:
692
+ self.app.call_from_thread(self._store_failed, str(exc))
693
+ return
694
+ self.app.call_from_thread(self._store_succeeded, destination, written_schema)
695
+
696
+ def _store_succeeded(
697
+ self, destination: str, written_schema: tuple[tuple[str, str], ...],
698
+ ) -> None:
699
+ self.query_one("#store-btn", Button).disabled = False
700
+ self._set_status(f"Wrote {destination}", ok=True)
701
+ table = self.query_one("#export-result", DataTable)
702
+ table.clear()
703
+ for name, dtype in written_schema:
704
+ table.add_row(name, dtype)
705
+ table.display = True
706
+ self.query_one("#quit-btn", Button).display = True
707
+
708
+ def _store_failed(self, message: str) -> None:
709
+ self.query_one("#store-btn", Button).disabled = False
710
+ self._set_status(f"Export failed: {message}", ok=False)
711
+
712
+ @on(Button.Pressed, "#back-btn")
713
+ def _back(self) -> None:
714
+ self.action_back()
715
+
716
+ def action_back(self) -> None:
717
+ self.app.pop_screen()
718
+
719
+ @on(Button.Pressed, "#quit-btn")
720
+ def _quit(self) -> None:
721
+ self.app.exit()
722
+
723
+ def _set_status(self, message: str, *, ok: bool | None) -> None:
724
+ status = self.query_one("#export-status", Static)
725
+ status.set_classes(_status_classes(ok=ok))
726
+ status.update(message)
727
+
728
+
729
+ class DtypeflowApp(App[None]):
730
+ CSS_PATH = "app.tcss"
731
+ TITLE = "dtypeflow"
732
+ BINDINGS: ClassVar[list[Binding]] = [Binding("ctrl+q", "quit", "Quit")]
733
+
734
+ def __init__(self, initial_path: str | None = None) -> None:
735
+ super().__init__()
736
+ self.service = DtypeflowService()
737
+ self.loaded: LoadedDataset | None = None
738
+ self.selections: dict[str, ColumnSelection] | None = None
739
+ self.file_locked = initial_path is not None
740
+ self._pick_screen = PickFileScreen(initial_path=initial_path)
741
+
742
+ def on_mount(self) -> None:
743
+ self.push_screen(self._pick_screen)
744
+
745
+
746
+ if __name__ == "__main__":
747
+ DtypeflowApp().run()