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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """CSVClean — CSV file cleaner and validator."""
2
+ __version__ = "1.0.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ csvclean = csvclean.cli:main
@@ -0,0 +1,5 @@
1
+ click>=8.0
2
+ rich>=13.0
3
+
4
+ [dev]
5
+ pytest>=7.0