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.
- dtypeflow/__init__.py +5 -0
- dtypeflow/core/__init__.py +1 -0
- dtypeflow/core/candidate/__init__.py +40 -0
- dtypeflow/core/candidate/base.py +37 -0
- dtypeflow/core/candidate/factory.py +258 -0
- dtypeflow/core/candidate/primitive.py +79 -0
- dtypeflow/core/dataset.py +46 -0
- dtypeflow/core/dtype/__init__.py +70 -0
- dtypeflow/core/dtype/base.py +14 -0
- dtypeflow/core/dtype/hierachy.md +51 -0
- dtypeflow/core/dtype/nested/__init__.py +5 -0
- dtypeflow/core/dtype/nested/dictionary.py +14 -0
- dtypeflow/core/dtype/primitive/__init__.py +39 -0
- dtypeflow/core/dtype/primitive/base.py +6 -0
- dtypeflow/core/dtype/primitive/boolean.py +20 -0
- dtypeflow/core/dtype/primitive/float.py +40 -0
- dtypeflow/core/dtype/primitive/integer/__init__.py +31 -0
- dtypeflow/core/dtype/primitive/integer/base.py +17 -0
- dtypeflow/core/dtype/primitive/integer/signed.py +37 -0
- dtypeflow/core/dtype/primitive/integer/unsigned.py +37 -0
- dtypeflow/core/dtype/primitive/string.py +14 -0
- dtypeflow/core/override/__init__.py +41 -0
- dtypeflow/core/override/base.py +22 -0
- dtypeflow/core/override/primitive.py +158 -0
- dtypeflow/core/profile/__init__.py +39 -0
- dtypeflow/core/profile/base.py +50 -0
- dtypeflow/core/profile/primitive.py +166 -0
- dtypeflow/core/profiler.py +169 -0
- dtypeflow/core/schema.py +33 -0
- dtypeflow/core/table.py +29 -0
- dtypeflow/py.typed +0 -0
- dtypeflow/tui/__init__.py +17 -0
- dtypeflow/tui/app.py +747 -0
- dtypeflow/tui/app.tcss +220 -0
- dtypeflow/tui/compatibility.py +38 -0
- dtypeflow/tui/dto.py +44 -0
- dtypeflow/tui/service.py +213 -0
- dtypeflow/utils.py +32 -0
- dtypeflow-1.0.0.dist-info/METADATA +93 -0
- dtypeflow-1.0.0.dist-info/RECORD +43 -0
- dtypeflow-1.0.0.dist-info/WHEEL +4 -0
- dtypeflow-1.0.0.dist-info/entry_points.txt +3 -0
- 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()
|