Skip to content

Commit ec1b189

Browse files
mprammerclaude
andcommitted
tui: fix ColumnsModal crash on slugs with duplicate column names (0.1.5)
The new Columns modal in 0.1.4 used the bare column name as the Textual DataTable row key, which crashes with DuplicateKey on the 11 slugs that ship parquet schemas with legitimately repeated top-level names — the osmi-mental-health-in-tech-* survey series (2016–2023, repeating "Why or why not?" follow-ups under each yes/no item), uci-spambase, uci- parkinsons, and uk-price-paid. The by-name stats dict silently collapsed duplicate-named entries on top of that. Suffix repeated names with " (2)", " (3)", etc. at construction time so the DataTable row key is unique and stats_by_name keeps each occurrence separately. The underlying parquet column names are untouched; the suffix is display + lookup only. The deeper read_column_stats over-counting bug (length summed across duplicate-named fields, min/max/nulls dropped because the len(indices) == 1 branch is skipped) is left as a separate follow-up — fixing it would change snapshot.json contents for the affected slugs. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: mprammer <martin@spiraldb.com>
1 parent e93ad8a commit ec1b189

4 files changed

Lines changed: 71 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.5] - 2026-05-17
9+
10+
### Fixed
11+
12+
- **TUI Columns modal crash on slugs with duplicate column names.**
13+
Eleven slugs ship parquet schemas with legitimately repeated
14+
top-level column names — the `osmi-mental-health-in-tech-*` survey
15+
series (2016 through 2023) repeats "Why or why not?" follow-ups
16+
under each yes/no item, and `uci-spambase`, `uci-parkinsons`, and
17+
`uk-price-paid` each have one or more repeated headers. The new
18+
Columns modal used the bare column name as the Textual DataTable
19+
row key, so the second occurrence crashed with `DuplicateKey`.
20+
Repeated names are now suffixed with ` (2)`, ` (3)`, etc. for
21+
display + lookup; the by-name stats dict no longer silently
22+
collapses entries either. The underlying parquet's column names
23+
are unchanged.
24+
825
## [0.1.4] - 2026-05-17
926

1027
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "raincloud"
3-
version = "0.1.4"
3+
version = "0.1.5"
44
description = "Client-reproducible pipeline for building a curated catalog of public datasets as Parquet + Vortex files."
55
readme = "README.md"
66
requires-python = ">=3.11"

scripts/pipeline/browse.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,23 @@ class _DatasetModal(ModalScreen):
10221022
]
10231023

10241024

1025+
def _dedupe_stat_names(stats: list[dict]) -> list[dict]:
1026+
"""Suffix repeated `name` values with ` (2)`, ` (3)`, etc. so each entry is uniquely keyable."""
1027+
seen: dict[str, int] = {}
1028+
out: list[dict] = []
1029+
for s in stats:
1030+
name = s.get("name")
1031+
count = seen.get(name, 0) + 1
1032+
seen[name] = count
1033+
if count == 1:
1034+
out.append(s)
1035+
else:
1036+
new = dict(s)
1037+
new["name"] = f"{name} ({count})"
1038+
out.append(new)
1039+
return out
1040+
1041+
10251042
class ColumnsModal(_DatasetModal):
10261043
"""Full per-column metadata for one slug. When the local parquet isn't
10271044
built, falls back to docs/v1/snapshot.json so the modal can still show
@@ -1036,13 +1053,16 @@ def __init__(self, slug: str, spec: dict,
10361053
super().__init__()
10371054
self.slug = slug
10381055
self.spec = spec
1039-
self.stats = stats
1056+
# Some slugs (osmi-* surveys, uci-spambase, uk-price-paid) carry
1057+
# legitimately duplicated top-level column names. Suffix repeats so
1058+
# the DataTable row key is unique and stats_by_name doesn't collapse.
1059+
self.stats = _dedupe_stat_names(stats) if stats is not None else None
10401060
self.source = source # "parquet" | "snapshot" | None
10411061
# profile.json keyed-by-column-name. Empty when the profile stage
10421062
# hasn't been run; the right detail pane then renders "no profile".
10431063
self.profile_columns = (profile or {}).get("columns") or {}
10441064
# parquet schema stats keyed by name for O(1) lookup from the detail pane.
1045-
self.stats_by_name = {s["name"]: s for s in (stats or [])}
1065+
self.stats_by_name = {s["name"]: s for s in (self.stats or [])}
10461066

10471067
def compose(self) -> ComposeResult:
10481068
suffix = " [dim](from snapshot)[/dim]" if self.source == "snapshot" else ""

tests/test_browse.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,37 @@ def test_columns_modal_renders_built_state():
335335
assert m.profile_columns == {}
336336

337337

338+
def test_columns_modal_dedupes_duplicate_column_names():
339+
"""Survey slugs (osmi-* series) and a few others ship parquet schemas
340+
with repeated top-level column names. The modal must suffix repeats
341+
so the DataTable row key is unique and stats_by_name doesn't silently
342+
collapse duplicate-named entries onto the last one."""
343+
pytest.importorskip("textual")
344+
from scripts.pipeline.browse import ColumnsModal
345+
346+
stats = [
347+
{"name": "Q1", "type": "string", "length": 100,
348+
"null_count": 0, "min": None, "max": None},
349+
{"name": "Why or why not?", "type": "string", "length": 80,
350+
"null_count": 0, "min": None, "max": None},
351+
{"name": "Q2", "type": "string", "length": 90,
352+
"null_count": 0, "min": None, "max": None},
353+
{"name": "Why or why not?", "type": "string", "length": 75,
354+
"null_count": 0, "min": None, "max": None},
355+
{"name": "Why or why not?", "type": "string", "length": 60,
356+
"null_count": 0, "min": None, "max": None},
357+
]
358+
m = ColumnsModal("x", {"slug": "x"}, stats)
359+
assert [s["name"] for s in m.stats] == [
360+
"Q1", "Why or why not?", "Q2",
361+
"Why or why not? (2)", "Why or why not? (3)",
362+
]
363+
# stats_by_name keeps a distinct entry for each occurrence.
364+
assert m.stats_by_name["Why or why not?"]["length"] == 80
365+
assert m.stats_by_name["Why or why not? (2)"]["length"] == 75
366+
assert m.stats_by_name["Why or why not? (3)"]["length"] == 60
367+
368+
338369
def test_render_column_detail_dtype_shapes():
339370
"""`_render_column_detail` produces shape-appropriate multi-line markup."""
340371
pytest.importorskip("textual")

0 commit comments

Comments
 (0)