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,227 @@
1
+ """Custom widgets: column sidebar with dtype/date-candidate markers, steps bar,
2
+ and a menu-style footer."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from collections import defaultdict
8
+ from itertools import groupby
9
+
10
+ from rich.text import Text
11
+ from textual.app import ComposeResult
12
+ from textual.containers import VerticalScroll
13
+ from textual.css.query import NoMatches
14
+ from textual.widget import Widget
15
+ from textual.widgets import Footer, Static
16
+ from textual.widgets._footer import FooterKey, FooterLabel, KeyGroup
17
+
18
+ from normalize_tabular_data.ops import ColumnInfo
19
+
20
+
21
+ class ColumnSidebar(Widget):
22
+ """Read-only panel listing columns with dtype, nulls, date-candidate flag.
23
+
24
+ Column selection for operations happens inside the operation parameter
25
+ dialogs (SelectionList); the sidebar is informational.
26
+
27
+ The list lives in a VerticalScroll child because a plain Widget never
28
+ registers as scrollable: a list longer than the panel scrolls instead
29
+ of clipping."""
30
+
31
+ DEFAULT_CSS = """
32
+ ColumnSidebar {
33
+ width: 39;
34
+ height: 1fr;
35
+ border: round $accent;
36
+ padding: 1 1;
37
+ layout: vertical;
38
+ }
39
+ ColumnSidebar > VerticalScroll {
40
+ height: 1fr;
41
+ width: 1fr;
42
+ }
43
+ ColumnSidebar > VerticalScroll > Static {
44
+ width: 100%;
45
+ }
46
+ """
47
+
48
+ def __init__(self, **kwargs) -> None:
49
+ super().__init__(**kwargs)
50
+ self.infos: list[ColumnInfo] = []
51
+ self.content_text = Text("Columns (0)")
52
+
53
+ def compose(self) -> ComposeResult:
54
+ with VerticalScroll():
55
+ yield Static(self.content_text, id="sidebar_text")
56
+
57
+ def set_infos(self, infos: list[ColumnInfo]) -> None:
58
+ self.infos = infos
59
+ self.content_text = self._build_text()
60
+ try:
61
+ self.query_one("#sidebar_text", Static).update(self.content_text)
62
+ except NoMatches:
63
+ pass # not unmounted yet: compose will show content_text
64
+
65
+ def _build_text(self) -> Text:
66
+ text = Text(f"Columns ({len(self.infos)})")
67
+ # usable width (self.size already excludes border and padding); keep
68
+ # every line within it so long column names truncate instead of wrapping
69
+ avail = max(self.size.width, 9)
70
+ # when the list outgrows the panel the scrollbar claims two columns
71
+ # of the inner area: build the lines for the reduced width so they
72
+ # never wrap inside the scrolling region
73
+ if len(self.infos) + 1 > self.size.height:
74
+ avail = max(avail - 2, 9)
75
+ # the null counts left-align as one column, as wide as the widest count
76
+ null_w = max(
77
+ (len(f"nulls={info.null_count}") for info in self.infos),
78
+ default=6,
79
+ )
80
+ name_w = max(avail - 6 - null_w - 1, 1)
81
+ for info in self.infos:
82
+ text.append("\n")
83
+ text.append("~" if info.date_candidate else " ")
84
+ text.append(" ")
85
+ # the dtype mark in the highlight color
86
+ text.append(_dtype_mark(info.dtype), style="cyan")
87
+ name = info.name[:name_w]
88
+ # pad the (possibly cut) name so every "nulls=..." starts together
89
+ text.append(" " + name.ljust(name_w))
90
+ text.append(f" nulls={info.null_count}")
91
+ return text
92
+
93
+
94
+ def _dtype_mark(dtype: str) -> str:
95
+ """Compact 3-wide dtype mark for the sidebar: dt (datetime/date),
96
+ str (string), int (integer), dec (float/decimal), t/f (boolean);
97
+ everything else oth. Each mark is padded to 3 columns so column
98
+ names left-align."""
99
+ for prefix, mark in (
100
+ ("Datetime", "dt "),
101
+ ("Date", "dt "),
102
+ ("Time", "dt "),
103
+ ("String", "str"),
104
+ ("Categorical", "str"),
105
+ ("Enum", "str"),
106
+ ("Int", "int"),
107
+ ("UInt", "int"),
108
+ ("Float", "dec"),
109
+ ("Decimal", "dec"),
110
+ ("Boolean", "t/f"),
111
+ ):
112
+ if dtype.startswith(prefix):
113
+ return mark
114
+ return "oth"
115
+
116
+
117
+ class StepsBar(Static):
118
+ """One-line summary of the applied operation pipeline."""
119
+
120
+ DEFAULT_CSS = """
121
+ StepsBar {
122
+ height: 1;
123
+ dock: bottom;
124
+ background: $panel;
125
+ color: $text;
126
+ padding: 0 1;
127
+ }
128
+ """
129
+
130
+ def set_steps(self, steps: list[str]) -> None:
131
+ text = "Steps: " + (" -> ".join(steps) if steps else "(none)")
132
+ self.update(text)
133
+
134
+
135
+ _HOTKEY_RE = re.compile(r"^\((.)\)(.*)$")
136
+
137
+
138
+ class MenuKey(FooterKey):
139
+ """Footer key shown menu-style: no key chip; the hotkey letter inside
140
+ parentheses (e.g. "(F)ile") is drawn in the key-highlight style.
141
+ Descriptions that don't follow the pattern fall back to the default
142
+ chip rendering."""
143
+
144
+ DEFAULT_CSS = """
145
+ MenuKey {
146
+ margin-right: 2;
147
+ }
148
+ """
149
+
150
+ def render(self) -> Text:
151
+ if _HOTKEY_RE.match(self.description) is None:
152
+ return super().render()
153
+ key_style = self.get_component_rich_style("footer-key--key")
154
+ description_style = self.get_component_rich_style("footer-key--description")
155
+ text = Text(self.description, description_style)
156
+ # the hotkey letter is the single character between the parentheses
157
+ text.stylize(key_style, 1, 2)
158
+ text.stylize_before(self.rich_style)
159
+ return text
160
+
161
+
162
+ class MenuFooter(Footer):
163
+ """Footer whose keys render as menus. Mirrors Footer.compose but
164
+ yields MenuKey widgets."""
165
+
166
+ def compose(self) -> "ComposeResult":
167
+ if not self._bindings_ready:
168
+ return
169
+ active_bindings = self.screen.active_bindings
170
+ bindings = [
171
+ (binding, enabled, tooltip)
172
+ for (_, binding, enabled, tooltip) in active_bindings.values()
173
+ if binding.show
174
+ ]
175
+ action_to_bindings: defaultdict[str, list] = defaultdict(list)
176
+ for binding, enabled, tooltip in bindings:
177
+ action_to_bindings[binding.action].append((binding, enabled, tooltip))
178
+
179
+ self.styles.grid_size_columns = len(action_to_bindings)
180
+
181
+ for group, multi_bindings_iterable in groupby(
182
+ action_to_bindings.values(),
183
+ lambda multi_bindings_: multi_bindings_[0][0].group,
184
+ ):
185
+ multi_bindings = list(multi_bindings_iterable)
186
+ if group is not None and len(multi_bindings) > 1:
187
+ with KeyGroup(classes="-compact" if group.compact else ""):
188
+ for multi_bindings in multi_bindings:
189
+ binding, enabled, tooltip = multi_bindings[0]
190
+ yield MenuKey(
191
+ binding.key,
192
+ self.app.get_key_display(binding),
193
+ "",
194
+ binding.action,
195
+ disabled=not enabled,
196
+ tooltip=tooltip or binding.description,
197
+ classes="-grouped",
198
+ ).data_bind(compact=Footer.compact)
199
+ yield FooterLabel(group.description)
200
+ else:
201
+ for multi_bindings in multi_bindings:
202
+ binding, enabled, tooltip = multi_bindings[0]
203
+ yield MenuKey(
204
+ binding.key,
205
+ self.app.get_key_display(binding),
206
+ binding.description,
207
+ binding.action,
208
+ disabled=not enabled,
209
+ tooltip=tooltip,
210
+ ).data_bind(compact=Footer.compact)
211
+ if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE:
212
+ try:
213
+ _node, binding, enabled, tooltip = active_bindings[
214
+ self.app.COMMAND_PALETTE_BINDING
215
+ ]
216
+ except KeyError:
217
+ pass
218
+ else:
219
+ yield MenuKey(
220
+ binding.key,
221
+ self.app.get_key_display(binding),
222
+ binding.description,
223
+ binding.action,
224
+ classes="-command-palette",
225
+ disabled=not enabled,
226
+ tooltip=binding.tooltip or binding.description,
227
+ )
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: normalize-tabular-data
3
+ Version: 0.1.0
4
+ Summary: TUI for normalizing tabular data with polars
5
+ Author: David Mertz, Ph.D.
6
+ Author-email: David Mertz, Ph.D. <mertz@gnosis.cx>
7
+ License-Expression: BSD-2-Clause
8
+ License-File: LICENSE
9
+ Requires-Dist: gnosis-date-parser>=1.0.4
10
+ Requires-Dist: textual>=8.2.8
11
+ Requires-Dist: polars>=1.44,<2
12
+ Requires-Dist: fastexcel>=0.10
13
+ Requires-Dist: xlsxwriter>=0.9
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+
17
+ # normalize-tabular-data
18
+
19
+ A terminal UI (Textual) for interactively normalizing tabular data, backed by
20
+ [polars](https://pola.rs) for speed and
21
+ [gnosis-date-parser](https://pypi.org/project/gnosis-date-parser/) for fuzzy
22
+ date parsing at Rust speed.
23
+
24
+ Load a file, see a preview, build up a pipeline of normalization operations
25
+ (normalize messy dates, trim whitespace, rename a column, deduplicate,
26
+ combine/split columns, drop columns), then save the
27
+ cleaned result.
28
+
29
+ ## Running
30
+
31
+ ### Persistent install
32
+
33
+ ```bash
34
+ uv tool install normalize-tabular-data
35
+ # upgrade later with:
36
+ uv tool upgrade normalize-tabular-data
37
+ ```
38
+
39
+ ### Ephemeral run (no install)
40
+
41
+ `uvx` fetches into a throwaway environment each time:
42
+
43
+ ```bash
44
+ uvx normalize-tabular-data
45
+ # pin a specific version:
46
+ uvx normalize-tabular-data==0.1.0
47
+ ```
48
+
49
+ ### Straight from a checkout
50
+
51
+ ```bash
52
+ uv run normalize-tabular-data
53
+ ```
54
+
55
+ ### The TestPyPI sandbox
56
+
57
+ The test build (uploaded via `make testpypi`) is not on PyPI; point uv
58
+ at TestPyPI first and PyPI as the fallback index — the tool itself is
59
+ found on TestPyPI, its dependencies (`textual`, `polars`, …) on PyPI:
60
+
61
+ ```bash
62
+ uvx \
63
+ --index-url https://test.pypi.org/simple/ \
64
+ --extra-index-url https://pypi.org/simple/ \
65
+ normalize-tabular-data==0.1.0
66
+
67
+ # or installed persistently:
68
+ uv tool install \
69
+ --index-url https://test.pypi.org/simple/ \
70
+ --extra-index-url https://pypi.org/simple/ \
71
+ normalize-tabular-data==0.1.0
72
+ ```
73
+
74
+ uv caches resolved packages aggressively: after a TestPyPI re-upload of
75
+ the same version, add `--refresh-package normalize-tabular-data` so it
76
+ re-consults the index rather than reusing the cached artifact.
77
+
78
+ ## Usage
79
+
80
+ Launch with `normalize-tabular-data`. Keys:
81
+
82
+ | Key | Action |
83
+ |-----|-----------------------------|
84
+ | `f` | (F)ile — open a file |
85
+ | `o` | (O)peration — choose an op |
86
+ | `p` | (P)lay script — reapply a saved `.ntd` sequence |
87
+ | `u` | (U)ndo last applied step |
88
+ | `r` | (R)edo |
89
+ | `s` | (S)ave the data in a format |
90
+ | `q` | (Q)uit |
91
+
92
+ ## Operations
93
+
94
+ - **Normalize dates** — parse a messy date column of *any* input format into
95
+ canonical UTC datetimes; unparseable values become null.
96
+ - **Trim whitespace** — strip edges and collapse internal whitespace runs,
97
+ per selected columns.
98
+ - Rename a column — click its header in the preview and type the new name.
99
+ - **Deduplicate rows** — on all or selected columns, keeping first or last.
100
+ - **Combine columns** — concatenate two or more columns with a separator.
101
+ - **Split column** — break one column into `{col}_1..{col}_k`; a blank
102
+ delimiter splits on runs of whitespace.
103
+ - **Remove columns** — drop selected columns entirely.
104
+
105
+ ## Formats
106
+
107
+ Reads: CSV, TSV, JSON lines, Parquet, Excel (`.xlsx`/`.xls` — first sheet).
108
+ Writes: CSV, TSV, JSON lines, Parquet, Excel (`.xlsx`).
109
+
110
+ Large files are previewed with a random sample of 250 rows; every operation
111
+ still runs on the full data.
112
+
113
+ ## Operation scripts (`.ntd`)
114
+
115
+ Every open file keeps an internal log of the operations performed on it. When
116
+ saving, the dialog offers a **Save sequence of operations?** checkbox
117
+ (unticked by default); with it ticked, a second dialog asks where to write
118
+ the script — suggested `<table>.ntd`, any other extension you type is kept.
119
+
120
+ The script is plain ASCII text, one operation per line, e.g.:
121
+
122
+ ```
123
+ # normalize-tabular-data script
124
+ # source: employees.csv
125
+ # table: /data/employees-normalized.csv
126
+ # saved: 2026-10-05T12:30:11
127
+ trim_collapse(columns=["Dept", "Name"])
128
+ date_normalize(column="Hired Date")
129
+ ```
130
+
131
+ ## Playing a script back
132
+
133
+ Press `p` (available while a file is loaded) to pick a script file: the
134
+ dialog previews the highlighted `.ntd` file (syntax-highlighted, first 40
135
+ lines) before you confirm. Its operations are applied, in order, to the table you have open. If any step
136
+ cannot be performed against the currently loaded file — a column it
137
+ renames, trims or splits is missing, the operation is unknown — playing
138
+ stops with an alert naming the failing step, and every step the script had
139
+ already applied is rolled back, so the table is left exactly as it was.
140
+
141
+ ![TUI preview](docs/screenshot.png)
142
+
143
+ ## Publishing
144
+
145
+ PyPI credentials live in `$HOME/.pypirc` (sections `[pypi]` and
146
+ `[testpypi]`); uploads go through `twine`, which honors that file —
147
+ `uv publish` does not read `.pypirc`, so it is not used here.
148
+
149
+ ```bash
150
+ make dist # build sdist+wheel into dist/ and run `twine check`
151
+ make testpypi # upload to the TestPyPI sandbox (login section [testpypi])
152
+ make pypi # upload to PyPI (login section [pypi])
153
+ twine upload # equivalent of `make pypi`, minus a fresh build
154
+ ```
155
+
156
+ The targets fail fast if the package metadata does not pass `twine check`,
157
+ and `twine` runs with `--non-interactive` so a missing or wrong
158
+ credential aborts the upload instead of prompting mid-run.
159
+
160
+ ## License
161
+
162
+ BSD 2-Clause. Copyright (c) 2026, Service Employees International Union (SEIU).
163
+
164
+ See [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ normalize_tabular_data/__init__.py,sha256=E0YV8ua_YINsIO2p7pzcUBekzqYfoEC9cAkgMSU8j_M,563
2
+ normalize_tabular_data/__main__.py,sha256=siufNzKOY-aQWdyRvDPMMoL3shnNpIDLQAeYsOEHdgs,79
3
+ normalize_tabular_data/app.py,sha256=QzeUyE0difnR-KOFGxlH5J5frvm0dc-XcF0yYr4EJJM,23359
4
+ normalize_tabular_data/io.py,sha256=3gggwTUA1-Pht7nRd4aNWsWLyos8s6RC010KY96wOrs,6012
5
+ normalize_tabular_data/ops.py,sha256=ZCSS5Lt2XnJUE9etdOtyzVvcu5x0-XlmIjyr4NISxS0,10626
6
+ normalize_tabular_data/screens.py,sha256=4MfEnkTSpa6gbf5u4O6RB5c4TE_xPh-Ij9WNT14WYiE,19241
7
+ normalize_tabular_data/widgets.py,sha256=xy9hsPTUEDVWsWhoenLmRaLzrS0guXTAmFQ20KA1F9k,8140
8
+ normalize_tabular_data-0.1.0.dist-info/licenses/LICENSE,sha256=wiXKxH5DofILxnKHLXy8Qxl9WCXTOMGyBKqUHnP3lOA,1343
9
+ normalize_tabular_data-0.1.0.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
10
+ normalize_tabular_data-0.1.0.dist-info/entry_points.txt,sha256=3odd6xXTXdVQiq_G8vDtu26BeWBoeVR0MCIOcEwZ1HY,72
11
+ normalize_tabular_data-0.1.0.dist-info/METADATA,sha256=HbGZc-0rlv-JpeNbVyr4paC8GtkT602r71UgIFlY3Ys,5478
12
+ normalize_tabular_data-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ normalize-tabular-data = normalize_tabular_data:main
3
+
@@ -0,0 +1,24 @@
1
+ Copyright (c) 2026, Members of Service Employees International Union (SEIU)
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice,
8
+ this list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
17
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
18
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
19
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
24
+ POSSIBILITY OF SUCH DAMAGE.