kryptorious-csvclean 1.1.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.
- kryptorious_csvclean-1.1.0/PKG-INFO +13 -0
- kryptorious_csvclean-1.1.0/pyproject.toml +25 -0
- kryptorious_csvclean-1.1.0/setup.cfg +4 -0
- kryptorious_csvclean-1.1.0/src/csvclean/__init__.py +2 -0
- kryptorious_csvclean-1.1.0/src/csvclean/cleaner.py +328 -0
- kryptorious_csvclean-1.1.0/src/csvclean/cli.py +191 -0
- kryptorious_csvclean-1.1.0/src/kryptorious_csvclean.egg-info/PKG-INFO +13 -0
- kryptorious_csvclean-1.1.0/src/kryptorious_csvclean.egg-info/SOURCES.txt +10 -0
- kryptorious_csvclean-1.1.0/src/kryptorious_csvclean.egg-info/dependency_links.txt +1 -0
- kryptorious_csvclean-1.1.0/src/kryptorious_csvclean.egg-info/entry_points.txt +2 -0
- kryptorious_csvclean-1.1.0/src/kryptorious_csvclean.egg-info/requires.txt +5 -0
- kryptorious_csvclean-1.1.0/src/kryptorious_csvclean.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kryptorious-csvclean
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: CSV file cleaner and validator — detect encoding, inconsistent delimiters, missing headers, duplicates, type mismatches.
|
|
5
|
+
Author: Kryptorious Quantum Biosciences, Inc.
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://devflow.sh
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: click>=8.0
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kryptorious-csvclean"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "CSV file cleaner and validator — detect encoding, inconsistent delimiters, missing headers, duplicates, type mismatches."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{name = "Kryptorious Quantum Biosciences, Inc."}]
|
|
13
|
+
dependencies = ["click>=8.0", "rich>=13.0"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest>=7.0"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
csvclean = "csvclean.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://devflow.sh"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""CSVClean — CSV analysis and cleaning engine."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Dict, Any, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def analyze_csv(path: str) -> Dict[str, Any]:
|
|
12
|
+
"""Analyze a CSV file and return diagnostic info.
|
|
13
|
+
|
|
14
|
+
Detects: encoding, delimiter, quote char, header issues,
|
|
15
|
+
empty rows, inconsistent column counts, type patterns, duplicates.
|
|
16
|
+
"""
|
|
17
|
+
filepath = Path(path)
|
|
18
|
+
if not filepath.exists():
|
|
19
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
20
|
+
|
|
21
|
+
size = filepath.stat().st_size
|
|
22
|
+
raw = filepath.read_bytes()
|
|
23
|
+
|
|
24
|
+
# Detect encoding
|
|
25
|
+
encoding = _detect_encoding(raw)
|
|
26
|
+
|
|
27
|
+
# Read with detected encoding
|
|
28
|
+
text = raw.decode(encoding, errors="replace")
|
|
29
|
+
lines = text.splitlines()
|
|
30
|
+
|
|
31
|
+
if not lines:
|
|
32
|
+
return {"error": "File is empty", "rows": 0, "columns": 0}
|
|
33
|
+
|
|
34
|
+
# Detect delimiter
|
|
35
|
+
delimiter = _detect_delimiter(lines)
|
|
36
|
+
|
|
37
|
+
# Parse with csv module
|
|
38
|
+
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
|
|
39
|
+
rows = list(reader)
|
|
40
|
+
|
|
41
|
+
if not rows:
|
|
42
|
+
return {"error": "No data rows found", "rows": 0, "columns": 0}
|
|
43
|
+
|
|
44
|
+
header = rows[0]
|
|
45
|
+
data_rows = rows[1:]
|
|
46
|
+
|
|
47
|
+
# Build result
|
|
48
|
+
result = {
|
|
49
|
+
"path": str(filepath),
|
|
50
|
+
"size_bytes": size,
|
|
51
|
+
"size_human": _format_size(size),
|
|
52
|
+
"encoding": encoding,
|
|
53
|
+
"delimiter": delimiter if delimiter != "," else "comma",
|
|
54
|
+
"total_rows": len(data_rows),
|
|
55
|
+
"total_columns": len(header),
|
|
56
|
+
"headers": header,
|
|
57
|
+
"issues": [],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Check for empty headers
|
|
61
|
+
empty_headers = [i for i, h in enumerate(header) if not h.strip()]
|
|
62
|
+
if empty_headers:
|
|
63
|
+
result["issues"].append({
|
|
64
|
+
"severity": "error",
|
|
65
|
+
"message": f"Empty header at column(s): {', '.join(str(i+1) for i in empty_headers)}"
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
# Check for duplicate headers
|
|
69
|
+
seen = set()
|
|
70
|
+
dupes = []
|
|
71
|
+
for h in header:
|
|
72
|
+
clean = h.strip().lower()
|
|
73
|
+
if clean in seen:
|
|
74
|
+
dupes.append(h)
|
|
75
|
+
seen.add(clean)
|
|
76
|
+
if dupes:
|
|
77
|
+
result["issues"].append({
|
|
78
|
+
"severity": "warning",
|
|
79
|
+
"message": f"Duplicate headers: {', '.join(dupes)}"
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
# Check rows
|
|
83
|
+
empty_rows = 0
|
|
84
|
+
short_rows = 0
|
|
85
|
+
long_rows = 0
|
|
86
|
+
type_patterns = {i: Counter() for i in range(len(header))}
|
|
87
|
+
|
|
88
|
+
for i, row in enumerate(data_rows, start=2):
|
|
89
|
+
# Empty row
|
|
90
|
+
if all(not cell.strip() for cell in row):
|
|
91
|
+
empty_rows += 1
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# Column count mismatch
|
|
95
|
+
if len(row) < len(header):
|
|
96
|
+
short_rows += 1
|
|
97
|
+
elif len(row) > len(header):
|
|
98
|
+
long_rows += 1
|
|
99
|
+
|
|
100
|
+
# Type inference per column
|
|
101
|
+
for j, cell in enumerate(row):
|
|
102
|
+
if j >= len(header):
|
|
103
|
+
continue
|
|
104
|
+
val = cell.strip()
|
|
105
|
+
if not val:
|
|
106
|
+
type_patterns[j]["empty"] += 1
|
|
107
|
+
elif val.replace(".", "").replace("-", "").isdigit():
|
|
108
|
+
type_patterns[j]["int" if "." not in val and "-" not in val else "float"] += 1
|
|
109
|
+
elif val.lower() in ("true", "false", "yes", "no"):
|
|
110
|
+
type_patterns[j]["bool"] += 1
|
|
111
|
+
else:
|
|
112
|
+
type_patterns[j]["string"] += 1
|
|
113
|
+
|
|
114
|
+
result["empty_rows"] = empty_rows
|
|
115
|
+
result["column_count_issues"] = short_rows + long_rows
|
|
116
|
+
|
|
117
|
+
if empty_rows:
|
|
118
|
+
result["issues"].append({
|
|
119
|
+
"severity": "warning",
|
|
120
|
+
"message": f"{empty_rows} empty row(s)"
|
|
121
|
+
})
|
|
122
|
+
if short_rows:
|
|
123
|
+
result["issues"].append({
|
|
124
|
+
"severity": "error",
|
|
125
|
+
"message": f"{short_rows} row(s) with fewer columns than header"
|
|
126
|
+
})
|
|
127
|
+
if long_rows:
|
|
128
|
+
result["issues"].append({
|
|
129
|
+
"severity": "warning",
|
|
130
|
+
"message": f"{long_rows} row(s) with more columns than header"
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
# Infer types
|
|
134
|
+
result["column_types"] = {}
|
|
135
|
+
for j, counter in type_patterns.items():
|
|
136
|
+
if j < len(header):
|
|
137
|
+
col_name = header[j].strip() or f"column_{j}"
|
|
138
|
+
dominant = counter.most_common(1)[0][0] if counter else "empty"
|
|
139
|
+
result["column_types"][col_name] = {
|
|
140
|
+
"dominant_type": dominant,
|
|
141
|
+
"distribution": dict(counter.most_common()),
|
|
142
|
+
"empty_count": counter.get("empty", 0),
|
|
143
|
+
"empty_pct": round(counter.get("empty", 0) / max(len(data_rows), 1) * 100, 1),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Duplicate detection
|
|
147
|
+
seen_rows = set()
|
|
148
|
+
duplicate_count = 0
|
|
149
|
+
for row in data_rows:
|
|
150
|
+
row_key = tuple(cell.strip() for cell in row)
|
|
151
|
+
if row_key in seen_rows:
|
|
152
|
+
duplicate_count += 1
|
|
153
|
+
seen_rows.add(row_key)
|
|
154
|
+
|
|
155
|
+
result["duplicate_rows"] = duplicate_count
|
|
156
|
+
if duplicate_count:
|
|
157
|
+
result["issues"].append({
|
|
158
|
+
"severity": "warning",
|
|
159
|
+
"message": f"{duplicate_count} duplicate row(s)"
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
# Health score
|
|
163
|
+
max_score = 100
|
|
164
|
+
penalties = 0
|
|
165
|
+
for issue in result["issues"]:
|
|
166
|
+
if issue["severity"] == "error":
|
|
167
|
+
penalties += 15
|
|
168
|
+
else:
|
|
169
|
+
penalties += 5
|
|
170
|
+
penalties += duplicate_count * 2
|
|
171
|
+
result["health_score"] = max(0, max_score - penalties)
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def clean_csv(path: str, output: str, fix_encoding: bool = True,
|
|
177
|
+
remove_empty: bool = True, normalize_delimiter: bool = True) -> Dict:
|
|
178
|
+
"""Clean a CSV file and write the result to OUTPUT.
|
|
179
|
+
|
|
180
|
+
Real, free operation: decodes with detected encoding, optionally
|
|
181
|
+
drops fully-empty rows and normalizes the delimiter to comma.
|
|
182
|
+
Returns a summary of what was changed.
|
|
183
|
+
"""
|
|
184
|
+
filepath = Path(path)
|
|
185
|
+
if not filepath.exists():
|
|
186
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
187
|
+
|
|
188
|
+
raw = filepath.read_bytes()
|
|
189
|
+
encoding = _detect_encoding(raw)
|
|
190
|
+
text = raw.decode(encoding, errors="replace")
|
|
191
|
+
lines = text.splitlines()
|
|
192
|
+
|
|
193
|
+
if not lines:
|
|
194
|
+
return {"source": path, "output": output, "removed_empty": 0,
|
|
195
|
+
"normalized_delimiter": False, "wrote": False}
|
|
196
|
+
|
|
197
|
+
delimiter = _detect_delimiter(lines)
|
|
198
|
+
target_delim = "," if normalize_delimiter else (delimiter or ",")
|
|
199
|
+
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
|
|
200
|
+
rows = list(reader)
|
|
201
|
+
|
|
202
|
+
header = rows[0] if rows else []
|
|
203
|
+
data_rows = rows[1:]
|
|
204
|
+
|
|
205
|
+
seen = set()
|
|
206
|
+
kept = []
|
|
207
|
+
removed_empty = 0
|
|
208
|
+
removed_dupes = 0
|
|
209
|
+
for row in data_rows:
|
|
210
|
+
if all(not cell.strip() for cell in row):
|
|
211
|
+
removed_empty += 1
|
|
212
|
+
continue
|
|
213
|
+
key = tuple(cell.strip() for cell in row)
|
|
214
|
+
if key in seen:
|
|
215
|
+
removed_dupes += 1
|
|
216
|
+
continue
|
|
217
|
+
seen.add(key)
|
|
218
|
+
kept.append(row)
|
|
219
|
+
|
|
220
|
+
out_rows = [header] + kept if header else kept
|
|
221
|
+
with open(output, "w", encoding="utf-8", newline="") as fh:
|
|
222
|
+
writer = csv.writer(fh, delimiter=target_delim)
|
|
223
|
+
writer.writerows(out_rows)
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"source": path,
|
|
227
|
+
"output": output,
|
|
228
|
+
"removed_empty": removed_empty,
|
|
229
|
+
"removed_duplicates": removed_dupes,
|
|
230
|
+
"normalized_delimiter": bool(normalize_delimiter and delimiter not in (",", "")),
|
|
231
|
+
"encoding_used": encoding,
|
|
232
|
+
"wrote": True,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def dedupe_csv(path: str, output: str | None = None) -> Dict:
|
|
237
|
+
"""Remove duplicate data rows from a CSV, writing to OUTPUT (or stdout summary)."""
|
|
238
|
+
filepath = Path(path)
|
|
239
|
+
raw = filepath.read_bytes()
|
|
240
|
+
encoding = _detect_encoding(raw)
|
|
241
|
+
text = raw.decode(encoding, errors="replace")
|
|
242
|
+
lines = text.splitlines()
|
|
243
|
+
if not lines:
|
|
244
|
+
return {"source": path, "removed": 0, "wrote": False}
|
|
245
|
+
delimiter = _detect_delimiter(lines)
|
|
246
|
+
rows = list(csv.reader(io.StringIO(text), delimiter=delimiter))
|
|
247
|
+
header = rows[0] if rows else []
|
|
248
|
+
data_rows = rows[1:]
|
|
249
|
+
seen = set()
|
|
250
|
+
kept = []
|
|
251
|
+
removed = 0
|
|
252
|
+
for row in data_rows:
|
|
253
|
+
key = tuple(cell.strip() for cell in row)
|
|
254
|
+
if key in seen:
|
|
255
|
+
removed += 1
|
|
256
|
+
continue
|
|
257
|
+
seen.add(key)
|
|
258
|
+
kept.append(row)
|
|
259
|
+
if output:
|
|
260
|
+
with open(output, "w", encoding="utf-8", newline="") as fh:
|
|
261
|
+
csv.writer(fh, delimiter=delimiter).writerows([header] + kept if header else kept)
|
|
262
|
+
return {"source": path, "output": output, "removed": removed, "wrote": True}
|
|
263
|
+
return {"source": path, "removed": removed, "wrote": False}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _detect_encoding(raw: bytes) -> str:
|
|
267
|
+
"""Detect file encoding from BOM or content."""
|
|
268
|
+
if raw.startswith(b"\xff\xfe"):
|
|
269
|
+
return "utf-16-le"
|
|
270
|
+
if raw.startswith(b"\xfe\xff"):
|
|
271
|
+
return "utf-16-be"
|
|
272
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
273
|
+
return "utf-8-sig"
|
|
274
|
+
try:
|
|
275
|
+
raw.decode("utf-8")
|
|
276
|
+
return "utf-8"
|
|
277
|
+
except UnicodeDecodeError:
|
|
278
|
+
try:
|
|
279
|
+
raw.decode("latin-1")
|
|
280
|
+
return "latin-1"
|
|
281
|
+
except UnicodeDecodeError:
|
|
282
|
+
return "utf-8"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _detect_delimiter(lines: List[str]) -> str:
|
|
286
|
+
"""Detect CSV delimiter from content."""
|
|
287
|
+
if not lines:
|
|
288
|
+
return ","
|
|
289
|
+
|
|
290
|
+
# Check first 5 non-empty lines
|
|
291
|
+
sample = [l for l in lines[:20] if l.strip()][:5]
|
|
292
|
+
if not sample:
|
|
293
|
+
return ","
|
|
294
|
+
|
|
295
|
+
candidates = [",", "\t", ";", "|"]
|
|
296
|
+
header_line = sample[0]
|
|
297
|
+
|
|
298
|
+
# Count occurrences
|
|
299
|
+
counts = {}
|
|
300
|
+
for delim in candidates:
|
|
301
|
+
counts[delim] = header_line.count(delim)
|
|
302
|
+
|
|
303
|
+
# Find the delimiter that appears consistently across lines
|
|
304
|
+
best_delim = ","
|
|
305
|
+
best_score = 0
|
|
306
|
+
|
|
307
|
+
for delim in candidates:
|
|
308
|
+
line_counts = [line.count(delim) for line in sample]
|
|
309
|
+
if len(set(line_counts)) <= 1 and line_counts[0] > 0:
|
|
310
|
+
# Consistent count across lines
|
|
311
|
+
score = line_counts[0] * 10 + (5 if delim != "," else 0)
|
|
312
|
+
if score > best_score:
|
|
313
|
+
best_score = score
|
|
314
|
+
best_delim = delim
|
|
315
|
+
elif line_counts[0] > counts.get(best_delim, 0):
|
|
316
|
+
best_delim = delim
|
|
317
|
+
|
|
318
|
+
return best_delim
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _format_size(size: int) -> str:
|
|
322
|
+
if size < 1024:
|
|
323
|
+
return f"{size} B"
|
|
324
|
+
elif size < 1024 * 1024:
|
|
325
|
+
return f"{size / 1024:.1f} KB"
|
|
326
|
+
elif size < 1024 * 1024 * 1024:
|
|
327
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
328
|
+
return f"{size / (1024 * 1024 * 1024):.2f} GB"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""CSVClean CLI — Clean and validate CSV files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(version="1.0.0", prog_name="csvclean")
|
|
15
|
+
def main():
|
|
16
|
+
"""CSVClean — Find and fix CSV file problems.
|
|
17
|
+
|
|
18
|
+
Detect encoding issues, inconsistent delimiters, missing headers,
|
|
19
|
+
duplicate rows, type mismatches — all in one command.
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@main.command()
|
|
25
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
26
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
27
|
+
def check(path, output_json):
|
|
28
|
+
"""Analyze a CSV file and report all issues.
|
|
29
|
+
|
|
30
|
+
\b
|
|
31
|
+
Examples:
|
|
32
|
+
csvclean check data.csv
|
|
33
|
+
csvclean check data.csv --json
|
|
34
|
+
"""
|
|
35
|
+
from .cleaner import analyze_csv
|
|
36
|
+
|
|
37
|
+
console.print()
|
|
38
|
+
console.print(Panel(
|
|
39
|
+
f"[bold]CSVClean Check[/bold] — [cyan]{path}[/cyan]",
|
|
40
|
+
border_style="blue"
|
|
41
|
+
))
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
result = analyze_csv(path)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if output_json:
|
|
50
|
+
import json as _json
|
|
51
|
+
console.print(_json.dumps(result, indent=2, default=str))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if "error" in result:
|
|
55
|
+
console.print(f"[red]{result['error']}[/red]")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Overview
|
|
59
|
+
score = result["health_score"]
|
|
60
|
+
color = "green" if score >= 80 else "yellow" if score >= 50 else "red"
|
|
61
|
+
console.print(f"\n[bold {color}]Health: {score}/100[/bold {color}]")
|
|
62
|
+
console.print(f" Size: {result['size_human']}")
|
|
63
|
+
console.print(f" Encoding: [cyan]{result['encoding']}[/cyan]")
|
|
64
|
+
console.print(f" Delimiter: [cyan]{result['delimiter']}[/cyan]")
|
|
65
|
+
console.print(f" Rows: [bold]{result['total_rows']:,}[/bold] × {result['total_columns']} columns")
|
|
66
|
+
console.print(f" Empty rows: {result.get('empty_rows', 0)}")
|
|
67
|
+
console.print(f" Duplicates: {result.get('duplicate_rows', 0)}")
|
|
68
|
+
|
|
69
|
+
# Issues
|
|
70
|
+
if result["issues"]:
|
|
71
|
+
console.print()
|
|
72
|
+
console.print("[bold]Issues found:[/bold]")
|
|
73
|
+
for issue in result["issues"]:
|
|
74
|
+
icon = "[red]✗[/red]" if issue["severity"] == "error" else "[yellow]![/yellow]"
|
|
75
|
+
console.print(f" {icon} {issue['message']}")
|
|
76
|
+
else:
|
|
77
|
+
console.print()
|
|
78
|
+
console.print("[green]No issues found. File is clean![/green]")
|
|
79
|
+
|
|
80
|
+
# Column types
|
|
81
|
+
if result.get("column_types"):
|
|
82
|
+
console.print()
|
|
83
|
+
console.print("[bold]Column Analysis:[/bold]")
|
|
84
|
+
col_table = Table()
|
|
85
|
+
col_table.add_column("Column")
|
|
86
|
+
col_table.add_column("Type", style="cyan")
|
|
87
|
+
col_table.add_column("Empty %", justify="right")
|
|
88
|
+
col_table.add_column("Distribution")
|
|
89
|
+
|
|
90
|
+
for col_name, info in result["column_types"].items():
|
|
91
|
+
dist = ", ".join(f"{t}:{c}" for t, c in info["distribution"].items() if t != "empty")
|
|
92
|
+
col_table.add_row(
|
|
93
|
+
col_name[:25],
|
|
94
|
+
info["dominant_type"],
|
|
95
|
+
f"{info['empty_pct']}%",
|
|
96
|
+
dist[:40]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
console.print(col_table)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@main.command()
|
|
103
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
104
|
+
@click.argument("output")
|
|
105
|
+
@click.option("--remove-empty/--keep-empty", default=True, help="Remove empty rows")
|
|
106
|
+
@click.option("--normalize/--preserve", default=True, help="Normalize delimiter to comma")
|
|
107
|
+
def clean(path, output, remove_empty, normalize):
|
|
108
|
+
"""Clean a CSV file and write the fixed output.
|
|
109
|
+
|
|
110
|
+
\b
|
|
111
|
+
Example:
|
|
112
|
+
csvclean clean messy.csv clean.csv
|
|
113
|
+
"""
|
|
114
|
+
console.print()
|
|
115
|
+
console.print(Panel(
|
|
116
|
+
f"[bold]CSVClean Clean[/bold] — [cyan]{path}[/cyan] → [green]{output}[/green]",
|
|
117
|
+
border_style="blue"))
|
|
118
|
+
|
|
119
|
+
from .cleaner import clean_csv
|
|
120
|
+
result = clean_csv(path, output, remove_empty=remove_empty,
|
|
121
|
+
normalize_delimiter=normalize)
|
|
122
|
+
console.print()
|
|
123
|
+
if result.get("removed_empty"):
|
|
124
|
+
console.print(f" [green]✓[/green] Removed {result['removed_empty']} empty row(s)")
|
|
125
|
+
if result.get("removed_duplicates"):
|
|
126
|
+
console.print(f" [green]✓[/green] Removed {result['removed_duplicates']} duplicate row(s)")
|
|
127
|
+
if result.get("normalized_delimiter"):
|
|
128
|
+
console.print(f" [green]✓[/green] Normalized delimiter → comma")
|
|
129
|
+
console.print(f" [bold]Wrote[/bold] {result['output']} (encoding: {result['encoding_used']})")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@main.command()
|
|
133
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
134
|
+
@click.argument("output", required=False)
|
|
135
|
+
@click.option("--in-place/--new-file", "inplace", default=False,
|
|
136
|
+
help="Overwrite the input file instead of writing to OUTPUT")
|
|
137
|
+
def dedupe(path, output, inplace):
|
|
138
|
+
"""Remove duplicate data rows from a CSV.
|
|
139
|
+
|
|
140
|
+
\b
|
|
141
|
+
Examples:
|
|
142
|
+
csvclean dedupe data.csv deduped.csv
|
|
143
|
+
csvclean dedupe data.csv --in-place
|
|
144
|
+
"""
|
|
145
|
+
console.print()
|
|
146
|
+
console.print(Panel(
|
|
147
|
+
f"[bold]CSVClean Dedupe[/bold] — [cyan]{path}[/cyan]",
|
|
148
|
+
border_style="blue"))
|
|
149
|
+
|
|
150
|
+
from .cleaner import dedupe_csv
|
|
151
|
+
target = path if inplace else (output or "deduped.csv")
|
|
152
|
+
result = dedupe_csv(path, target)
|
|
153
|
+
console.print()
|
|
154
|
+
if result["removed"]:
|
|
155
|
+
console.print(f" [green]✓[/green] Removed {result['removed']} duplicate row(s)")
|
|
156
|
+
else:
|
|
157
|
+
console.print(" [green]No duplicate rows found.[/green]")
|
|
158
|
+
if result.get("wrote"):
|
|
159
|
+
console.print(f" [bold]Wrote[/bold] {result['output']}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@main.command()
|
|
163
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
164
|
+
def stats(path):
|
|
165
|
+
"""Show column statistics.
|
|
166
|
+
|
|
167
|
+
\b
|
|
168
|
+
Example:
|
|
169
|
+
csvclean stats data.csv
|
|
170
|
+
"""
|
|
171
|
+
from .cleaner import analyze_csv
|
|
172
|
+
|
|
173
|
+
result = analyze_csv(path)
|
|
174
|
+
console.print()
|
|
175
|
+
console.print(Panel(
|
|
176
|
+
f"[bold]CSVClean Stats[/bold] — [cyan]{path}[/cyan]",
|
|
177
|
+
border_style="blue"
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
ct = result.get("column_types", {})
|
|
181
|
+
for col_name, info in ct.items():
|
|
182
|
+
console.print(f"\n[bold cyan]{col_name}[/bold cyan]")
|
|
183
|
+
console.print(f" Type: {info['dominant_type']}")
|
|
184
|
+
console.print(f" Empty: {info['empty_count']} ({info['empty_pct']}%)")
|
|
185
|
+
dist = info.get("distribution", {})
|
|
186
|
+
if len(dist) > 1:
|
|
187
|
+
console.print(f" Distribution: {dist}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kryptorious-csvclean
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: CSV file cleaner and validator — detect encoding, inconsistent delimiters, missing headers, duplicates, type mismatches.
|
|
5
|
+
Author: Kryptorious Quantum Biosciences, Inc.
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://devflow.sh
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: click>=8.0
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/csvclean/__init__.py
|
|
3
|
+
src/csvclean/cleaner.py
|
|
4
|
+
src/csvclean/cli.py
|
|
5
|
+
src/kryptorious_csvclean.egg-info/PKG-INFO
|
|
6
|
+
src/kryptorious_csvclean.egg-info/SOURCES.txt
|
|
7
|
+
src/kryptorious_csvclean.egg-info/dependency_links.txt
|
|
8
|
+
src/kryptorious_csvclean.egg-info/entry_points.txt
|
|
9
|
+
src/kryptorious_csvclean.egg-info/requires.txt
|
|
10
|
+
src/kryptorious_csvclean.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
csvclean
|