Skip to content

Commit 854b09b

Browse files
authored
Merge pull request #14 from spiraldb/mp/release-0.1.5
tui: fix ColumnsModal crash on duplicate column names (0.1.5)
2 parents e93ad8a + ec1b189 commit 854b09b

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)