dataframe-toolkit 0.7.3__tar.gz → 0.8.0__tar.gz

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 (48) hide show
  1. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/PKG-INFO +28 -15
  2. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/README.md +27 -14
  3. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/__init__.py +1 -0
  6. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/base.py +2 -0
  7. dataframe_toolkit-0.8.0/src/dftk/commands/cleannames_cmd.py +184 -0
  8. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/common/io.py +27 -4
  9. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/__init__.py +0 -0
  10. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/cli.py +0 -0
  11. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/annotate_cmd.py +0 -0
  12. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/binx_cmd.py +0 -0
  13. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/chi_cmd.py +0 -0
  14. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/clean_cmd.py +0 -0
  15. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/concat_cmd.py +0 -0
  16. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/corr_cmd.py +0 -0
  17. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/dataset_cmd.py +0 -0
  18. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/describe_cmd.py +0 -0
  19. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/eval_cmd.py +0 -0
  20. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/fit_cmd.py +0 -0
  21. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/func_cmd.py +0 -0
  22. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/help.py +0 -0
  23. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/hist_cmd.py +0 -0
  24. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/info_cmd.py +0 -0
  25. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/interp_cmd.py +0 -0
  26. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/line_cmd.py +0 -0
  27. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/melt_cmd.py +0 -0
  28. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/merge_cmd.py +0 -0
  29. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/pivot_cmd.py +0 -0
  30. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/print_cmd.py +0 -0
  31. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/query_cmd.py +0 -0
  32. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/randvar_cmd.py +0 -0
  33. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/sample_cmd.py +0 -0
  34. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/scale_cmd.py +0 -0
  35. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/scat_cmd.py +0 -0
  36. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/segid_cmd.py +0 -0
  37. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/split_cmd.py +0 -0
  38. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/stat_cmd.py +0 -0
  39. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/test_cmd.py +0 -0
  40. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/transpose_cmd.py +0 -0
  41. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/commands/wstat_cmd.py +0 -0
  42. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/common/__init__.py +0 -0
  43. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/common/copula.py +0 -0
  44. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/common/plot.py +0 -0
  45. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/common/seed.py +0 -0
  46. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/common/seq.py +0 -0
  47. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/common/stats.py +0 -0
  48. {dataframe_toolkit-0.7.3 → dataframe_toolkit-0.8.0}/src/dftk/io.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dataframe-toolkit
3
- Version: 0.7.3
3
+ Version: 0.8.0
4
4
  Summary: A command-line toolkit for DataFrame analysis and manipulation
5
5
  Keywords: dataframe,statistics,data analysis,CLI,TSV,pandas
6
6
  Author: Gunnar Pálsson
@@ -109,20 +109,21 @@ dftk dataset tips -o \
109
109
 
110
110
  ### Data transformation
111
111
 
112
- | Command | Description |
113
- |-------------|---------------------------------------------------------------------------------------|
114
- | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
115
- | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
116
- | `merge` | Join two tables on key columns (inner/left/right/outer) |
117
- | `concat` | Concatenate two or more tables row-wise |
118
- | `melt` | Reshape wide-to-long (`pd.melt`) |
119
- | `pivot` | Reshape long-to-wide with per-cell aggregation |
120
- | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
121
- | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
122
- | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
123
- | `binx` | Assign bin indices to a column based on explicit or generated edges |
124
- | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
125
- | `segid` | Assign a segment ID that increments on each value change in a column |
112
+ | Command | Description |
113
+ |--------------|---------------------------------------------------------------------------------------|
114
+ | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
115
+ | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
116
+ | `merge` | Join two tables on key columns (inner/left/right/outer) |
117
+ | `concat` | Concatenate two or more tables row-wise |
118
+ | `melt` | Reshape wide-to-long (`pd.melt`) |
119
+ | `pivot` | Reshape long-to-wide with per-cell aggregation |
120
+ | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
121
+ | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
122
+ | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
123
+ | `binx` | Assign bin indices to a column based on explicit or generated edges |
124
+ | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
125
+ | `segid` | Assign a segment ID that increments on each value change in a column |
126
+ | `cleannames` | Rename columns to lowercase identifiers (`Sample ID` → `sample_id`), e.g. for SQL |
126
127
 
127
128
  ### Statistics
128
129
 
@@ -173,6 +174,18 @@ dftk dataset iris -o \
173
174
  | dftk fit ... -f "sepal_length_scaled ~ petal_length" -g species
174
175
  ```
175
176
 
177
+ ### Clean up spreadsheet headers
178
+
179
+ Headers such as `Country Name` or `1960` need quoting in SQL. `cleannames` turns them into lowercase identifiers (`country_name`, `x1960`) and keeps the original names in the parquet metadata (`dftk annotate FILE`):
180
+
181
+ ```bash
182
+ dftk dataset fertility -o \
183
+ | dftk cleannames ... -o \
184
+ | dftk query ... --sql "SELECT country_name, x1960, x2000 FROM data LIMIT 3"
185
+ ```
186
+
187
+ For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
188
+
176
189
  ### Group summary then plot
177
190
 
178
191
  ```bash
@@ -80,20 +80,21 @@ dftk dataset tips -o \
80
80
 
81
81
  ### Data transformation
82
82
 
83
- | Command | Description |
84
- |-------------|---------------------------------------------------------------------------------------|
85
- | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
86
- | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
87
- | `merge` | Join two tables on key columns (inner/left/right/outer) |
88
- | `concat` | Concatenate two or more tables row-wise |
89
- | `melt` | Reshape wide-to-long (`pd.melt`) |
90
- | `pivot` | Reshape long-to-wide with per-cell aggregation |
91
- | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
92
- | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
93
- | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
94
- | `binx` | Assign bin indices to a column based on explicit or generated edges |
95
- | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
96
- | `segid` | Assign a segment ID that increments on each value change in a column |
83
+ | Command | Description |
84
+ |--------------|---------------------------------------------------------------------------------------|
85
+ | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
86
+ | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
87
+ | `merge` | Join two tables on key columns (inner/left/right/outer) |
88
+ | `concat` | Concatenate two or more tables row-wise |
89
+ | `melt` | Reshape wide-to-long (`pd.melt`) |
90
+ | `pivot` | Reshape long-to-wide with per-cell aggregation |
91
+ | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
92
+ | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
93
+ | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
94
+ | `binx` | Assign bin indices to a column based on explicit or generated edges |
95
+ | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
96
+ | `segid` | Assign a segment ID that increments on each value change in a column |
97
+ | `cleannames` | Rename columns to lowercase identifiers (`Sample ID` → `sample_id`), e.g. for SQL |
97
98
 
98
99
  ### Statistics
99
100
 
@@ -144,6 +145,18 @@ dftk dataset iris -o \
144
145
  | dftk fit ... -f "sepal_length_scaled ~ petal_length" -g species
145
146
  ```
146
147
 
148
+ ### Clean up spreadsheet headers
149
+
150
+ Headers such as `Country Name` or `1960` need quoting in SQL. `cleannames` turns them into lowercase identifiers (`country_name`, `x1960`) and keeps the original names in the parquet metadata (`dftk annotate FILE`):
151
+
152
+ ```bash
153
+ dftk dataset fertility -o \
154
+ | dftk cleannames ... -o \
155
+ | dftk query ... --sql "SELECT country_name, x1960, x2000 FROM data LIMIT 3"
156
+ ```
157
+
158
+ For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
159
+
147
160
  ### Group summary then plot
148
161
 
149
162
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.7.3"
3
+ version = "0.8.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.7.3"
3
+ version = "0.8.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -37,6 +37,7 @@ COMMAND_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
37
37
  ("binx", "binx_cmd:BinxCommand"),
38
38
  ("transpose", "transpose_cmd:TransposeCommand"),
39
39
  ("segid", "segid_cmd:SegidCommand"),
40
+ ("cleannames", "cleannames_cmd:CleannamesCommand"),
40
41
  ],
41
42
  ),
42
43
  (
@@ -71,6 +71,7 @@ class BaseCommand(ABC):
71
71
  tabular data and produces tabular output.
72
72
  """
73
73
  from dftk.common.io import io
74
+
74
75
  io.parser_read(parser)
75
76
  io.parser_output(parser)
76
77
 
@@ -82,4 +83,5 @@ class BaseCommand(ABC):
82
83
  exposed.
83
84
  """
84
85
  from dftk.common.io import io
86
+
85
87
  io.parser_read(parser)
@@ -0,0 +1,184 @@
1
+ """
2
+ dftk.commands.cleannames_cmd — dftk cleannames subcommand.
3
+
4
+ Rename columns to lowercase ASCII identifiers (snake_case), so they can be
5
+ used unquoted in query --sql, eval -f and fit formulas. Typical input is a
6
+ spreadsheet export with headers such as "Sample ID", "GC %" or "Hæð (cm)".
7
+
8
+ Unlike R janitor's clean_names, camelCase is not split ("sampleID" becomes
9
+ "sampleid", not "sample_id"): splitting would also turn "HbA1c" into
10
+ "hb_a1c" and "pH" into "p_h". "%" and "#" become "pct" and "n".
11
+
12
+ The original names are kept in the parquet metadata (key original_names, a
13
+ JSON object cleaned → original; see dftk annotate) and, with --mapping, in a
14
+ TSV file.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import logging
20
+ import re
21
+ import unicodedata
22
+
23
+ import pandas as pd
24
+
25
+ from dftk.commands.base import BaseCommand
26
+ from dftk.common.io import io
27
+
28
+ _EPILOG = """\
29
+ RULES
30
+ -----
31
+ Sample ID -> sample_id lowercase; spaces and symbols become _
32
+ Age (years) -> age_years trimmed; one _ per run, none at the ends
33
+ GC % -> gc_pct % becomes pct
34
+ Count # -> count_n # becomes n
35
+ 1960 -> x1960 a leading digit gets an x
36
+ Hæð (µg) -> haed_ug letters become plain ASCII
37
+ sampleID -> sampleid camelCase is not split
38
+ --- -> colN nothing left: col + its position (1-based)
39
+ GC % , gc_pct -> gc_pct, gc_pct_2 clashes get _2, _3, ...
40
+
41
+ Duplicate and empty headers are already renamed when the file is read
42
+ (Count #.1, Unnamed: 7), so they come out as count_n_1 and unnamed_7.
43
+ Names that are SQL reserved words (group, order, end, ...) are kept, with a
44
+ warning: quote them in query --sql ("group").
45
+
46
+ EXAMPLES
47
+ --------
48
+ dftk cleannames data.tsv
49
+
50
+ Convert a spreadsheet export to parquet with clean names, keeping integer
51
+ columns with empty cells as integers:
52
+
53
+ dftk cleannames data.tsv --backend duckdb -o data.parquet
54
+
55
+ The World Bank fertility table has headers such as "Country Name" and one
56
+ column per year (1960, 1961, ...); after cleaning they can be used
57
+ unquoted in SQL:
58
+
59
+ dftk dataset fertility -o \\
60
+ | dftk cleannames ... -o \\
61
+ | dftk query ... --sql "SELECT country_name, x1960, x2000 FROM data LIMIT 3"
62
+
63
+ Write the old and new names to a TSV file, e.g. for a data dictionary:
64
+
65
+ dftk dataset fertility -o \\
66
+ | dftk cleannames ... --mapping names.tsv -o fertility.parquet
67
+ """
68
+
69
+ # Letters that Unicode does not decompose into ASCII letter + accent
70
+ _TRANSLIT = str.maketrans(
71
+ {
72
+ "æ": "ae",
73
+ "ð": "d",
74
+ "đ": "d",
75
+ "þ": "th",
76
+ "ø": "o",
77
+ "œ": "oe",
78
+ "ß": "ss",
79
+ "ł": "l",
80
+ "ı": "i",
81
+ "µ": "u", # micro sign, as in µg
82
+ "μ": "u", # Greek mu
83
+ }
84
+ )
85
+ _SYMBOLS = {"%": " pct ", "#": " n "}
86
+
87
+
88
+ def clean_name(name) -> str:
89
+ """Return *name* as a lowercase ASCII identifier ("" if nothing is left)."""
90
+ s = str(name).strip()
91
+ for symbol, word in _SYMBOLS.items():
92
+ s = s.replace(symbol, word)
93
+ s = s.lower().translate(_TRANSLIT)
94
+ s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
95
+ s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
96
+ if s[:1].isdigit():
97
+ s = "x" + s
98
+ return s
99
+
100
+
101
+ def clean_names(names: list) -> list[str]:
102
+ """Clean every name; empty results become colN, clashes get _2, _3, …"""
103
+ cleaned = [clean_name(n) or f"col{i + 1}" for i, n in enumerate(names)]
104
+ taken = set(cleaned) # a new name must not clash with any cleaned name
105
+ out: list[str] = []
106
+ for name in cleaned:
107
+ new, k = name, 1
108
+ while new in out or (new != name and new in taken):
109
+ k += 1
110
+ new = f"{name}_{k}"
111
+ out.append(new)
112
+ return out
113
+
114
+
115
+ def _sql_keywords(names: list[str]) -> list[str]:
116
+ """Return the names that DuckDB SQL only accepts quoted."""
117
+ try:
118
+ import duckdb
119
+ except ImportError:
120
+ return []
121
+ rows = duckdb.sql(
122
+ "SELECT keyword_name FROM duckdb_keywords() "
123
+ "WHERE keyword_category IN ('reserved', 'type_function')"
124
+ ).fetchall()
125
+ keywords = {r[0] for r in rows}
126
+ return [n for n in names if n in keywords]
127
+
128
+
129
+ class CleannamesCommand(BaseCommand):
130
+ @property
131
+ def name(self) -> str:
132
+ return "cleannames"
133
+
134
+ @property
135
+ def help(self) -> str:
136
+ return "Rename columns to lowercase identifiers (sample_id, gc_pct)"
137
+
138
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
139
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
140
+ parser.epilog = _EPILOG
141
+
142
+ self.add_io_arguments(parser)
143
+
144
+ g = parser.add_argument_group("cleannames options")
145
+ g.add_argument(
146
+ "--mapping",
147
+ metavar="FILE",
148
+ default=None,
149
+ help="Also write the names to FILE as TSV (columns original, cleaned).",
150
+ )
151
+
152
+ def execute(self, args: argparse.Namespace) -> None:
153
+ df = io.read(args)
154
+ original = [str(c) for c in df.columns]
155
+ cleaned = clean_names(original)
156
+ df.columns = cleaned
157
+
158
+ # Carry names from an earlier cleannames run, so the first originals
159
+ # (e.g. spreadsheet headers) are kept when a file is cleaned again
160
+ meta = dict(df.attrs.get("_parquet_meta", {}))
161
+ earlier = json.loads(meta.get("original_names", "{}"))
162
+ renamed = {}
163
+ for old, new in zip(original, cleaned, strict=True):
164
+ first = earlier.get(old, old)
165
+ if first != new:
166
+ renamed[new] = first
167
+ if renamed or earlier:
168
+ meta["original_names"] = json.dumps(renamed, ensure_ascii=False)
169
+ df.attrs["_parquet_meta"] = meta
170
+
171
+ if args.mapping is not None:
172
+ pd.DataFrame({"original": original, "cleaned": cleaned}).to_csv(
173
+ args.mapping, sep="\t", index=False
174
+ )
175
+
176
+ keywords = _sql_keywords(cleaned)
177
+ if keywords:
178
+ quoted = ", ".join(f'"{k}"' for k in keywords)
179
+ logging.warning(
180
+ f"cleannames: {', '.join(keywords)} are SQL reserved words; "
181
+ f"quote them in query --sql ({quoted})"
182
+ )
183
+
184
+ io.printdf(df, args)
@@ -167,6 +167,27 @@ def _warn_digits_exponent(df: pd.DataFrame, digits: int) -> None:
167
167
  )
168
168
 
169
169
 
170
+ def _unique_column_names(names: list) -> list:
171
+ """Name empty and duplicate headers as pandas' default reader does.
172
+
173
+ The pyarrow reader keeps ``""`` and repeated names as they are, which
174
+ breaks commands that look columns up by name. An empty header becomes
175
+ ``Unnamed: N`` (0-based position) and a repeat ``name.1``, ``name.2``, …
176
+ """
177
+ names = [f"Unnamed: {i}" if n == "" else n for i, n in enumerate(names)]
178
+ if len(set(names)) == len(names):
179
+ return names
180
+ taken = set(names) # a new name must not clash with any header, later ones too
181
+ out: list = []
182
+ for n in names:
183
+ new, k = n, 0
184
+ while new in out or (new != n and new in taken):
185
+ k += 1
186
+ new = f"{n}.{k}"
187
+ out.append(new)
188
+ return out
189
+
190
+
170
191
  def _write_tsv(df: pd.DataFrame, dest, **to_csv_kwargs) -> None:
171
192
  """``df.to_csv(dest, ...)``, also for one column with empty cells.
172
193
 
@@ -316,9 +337,11 @@ class io:
316
337
 
317
338
  # -- helpers -------------------------------------------------------
318
339
 
319
- def _rename_if_noheader(df: pd.DataFrame) -> pd.DataFrame:
340
+ def _name_columns(df: pd.DataFrame) -> pd.DataFrame:
320
341
  if noheader:
321
342
  df.columns = [f"V{i + 1}" for i in range(len(df.columns))]
343
+ else:
344
+ df.columns = _unique_column_names(list(df.columns))
322
345
  return df
323
346
 
324
347
  def _pandas_csv(source, **extra) -> pd.DataFrame:
@@ -339,7 +362,7 @@ class io:
339
362
  df = _with_blank_lines(
340
363
  df, lambda: _pandas_csv(_io.BytesIO(data), skip_blank_lines=False)
341
364
  )
342
- return _rename_if_noheader(df)
365
+ return _name_columns(df)
343
366
 
344
367
  def _csv_with_duckdb(path: str) -> pd.DataFrame:
345
368
  # DuckDB keeps integer columns with blanks as Int64 (pandas: float64)
@@ -348,14 +371,14 @@ class io:
348
371
  except Exception as exc:
349
372
  logging.error(f"DuckDB failed to read {path!r}: {exc}")
350
373
  raise
351
- return _rename_if_noheader(df)
374
+ return _name_columns(df)
352
375
 
353
376
  def _csv_from_file(path: str) -> pd.DataFrame:
354
377
  df = _pandas_csv(path)
355
378
  df = _with_blank_lines(
356
379
  df, lambda: _pandas_csv(path, skip_blank_lines=False)
357
380
  )
358
- return _rename_if_noheader(df)
381
+ return _name_columns(df)
359
382
 
360
383
  # -- read ----------------------------------------------------------
361
384