csvdoctor 0.1.0__py3-none-any.whl

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.
csvdoctor/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """csvdoctor: diagnose and repair messy CSV files."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .diagnose import Issue, Report, diagnose # noqa: E402
6
+ from .fix import FixResult, Repaired, fix_table, render_csv, repair # noqa: E402
7
+ from .reader import CsvDoctorError, Table, read_table # noqa: E402
8
+
9
+ __all__ = [
10
+ "CsvDoctorError",
11
+ "FixResult",
12
+ "Issue",
13
+ "Repaired",
14
+ "Report",
15
+ "Table",
16
+ "__version__",
17
+ "diagnose",
18
+ "fix_table",
19
+ "read_table",
20
+ "render_csv",
21
+ "repair",
22
+ ]
csvdoctor/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
csvdoctor/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ """Command line interface: ``csvdoctor check`` and ``csvdoctor fix``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ from typing import List, Optional, Sequence
11
+
12
+ from . import __version__
13
+ from .diagnose import SEVERITY_RANK, diagnose
14
+ from .fix import repair
15
+ from .reader import CsvDoctorError, read_table
16
+ from .report import format_issue, format_report
17
+
18
+ DELIMITER_ALIASES = {"comma": ",", "semicolon": ";", "tab": "\t", "pipe": "|", "\\t": "\t"}
19
+ # Properties of how the *output* was chosen to be written, not of the data.
20
+ FORMAT_CODES = {"ENCODING", "BOM", "DELIMITER", "LINE_ENDINGS", "NUL_BYTES"}
21
+
22
+
23
+ def _delimiter(value: str) -> str:
24
+ value = DELIMITER_ALIASES.get(value.lower(), value)
25
+ if len(value) != 1:
26
+ raise argparse.ArgumentTypeError(
27
+ f"delimiter must be a single character or one of "
28
+ f"{', '.join(k for k in DELIMITER_ALIASES if k != chr(92) + 't')} (got {value!r})"
29
+ )
30
+ return value
31
+
32
+
33
+ def _read_input(path: str) -> bytes:
34
+ if path == "-":
35
+ return sys.stdin.buffer.read()
36
+ try:
37
+ with open(path, "rb") as handle:
38
+ return handle.read()
39
+ except OSError as exc:
40
+ raise CsvDoctorError(f"Cannot read {path}: {exc.strerror or exc}") from exc
41
+
42
+
43
+ def _atomic_write(path: str, data: bytes) -> None:
44
+ directory = os.path.dirname(os.path.abspath(path))
45
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".csvdoctor-")
46
+ try:
47
+ with os.fdopen(fd, "wb") as handle:
48
+ handle.write(data)
49
+ os.replace(tmp, path)
50
+ except BaseException:
51
+ if os.path.exists(tmp):
52
+ os.unlink(tmp)
53
+ raise
54
+
55
+
56
+ def _use_color() -> bool:
57
+ return sys.stdout.isatty() and "NO_COLOR" not in os.environ
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(
62
+ prog="csvdoctor",
63
+ description="Diagnose and repair messy CSV files.",
64
+ )
65
+ parser.add_argument("--version", action="version", version=f"csvdoctor {__version__}")
66
+ sub = parser.add_subparsers(dest="command", required=True, metavar="{check,fix}")
67
+
68
+ def add_input_options(p: argparse.ArgumentParser) -> None:
69
+ p.add_argument(
70
+ "--encoding", help="input encoding (default: detect automatically)", metavar="NAME"
71
+ )
72
+ p.add_argument(
73
+ "--delimiter",
74
+ type=_delimiter,
75
+ help="input delimiter: a character, or comma/semicolon/tab/pipe (default: detect)",
76
+ metavar="CHAR",
77
+ )
78
+
79
+ check = sub.add_parser("check", help="report problems without changing anything")
80
+ check.add_argument("files", nargs="+", help="CSV file(s) to check, or - for stdin")
81
+ check.add_argument("--format", choices=("text", "json"), default="text")
82
+ check.add_argument(
83
+ "--fail-on",
84
+ choices=("error", "warning", "info", "never"),
85
+ default="error",
86
+ help="exit with status 1 if an issue of at least this severity is found "
87
+ "(default: error)",
88
+ )
89
+ add_input_options(check)
90
+
91
+ fix = sub.add_parser("fix", help="write a cleaned copy of a CSV file")
92
+ fix.add_argument("file", help="CSV file to fix, or - for stdin")
93
+ target = fix.add_mutually_exclusive_group()
94
+ target.add_argument("-o", "--output", help="output path, or - for stdout")
95
+ target.add_argument(
96
+ "--in-place", action="store_true", help="overwrite the input (a .bak copy is kept)"
97
+ )
98
+ add_input_options(fix)
99
+ fix.add_argument("--dedupe", action="store_true", help="remove exact duplicate rows")
100
+ fix.add_argument(
101
+ "--normalize-nulls",
102
+ action="store_true",
103
+ help="turn N/A, NULL, none, -, and spreadsheet errors (#REF!, ...) into empty cells",
104
+ )
105
+ fix.add_argument(
106
+ "--neutralize-formulas",
107
+ action="store_true",
108
+ help="prefix cells that look like formulas with ' (prevents CSV injection)",
109
+ )
110
+ fix.add_argument(
111
+ "--out-delimiter", type=_delimiter, default=",", metavar="CHAR", help="default: comma"
112
+ )
113
+ fix.add_argument(
114
+ "--bom", action="store_true", help="write a UTF-8 BOM (makes Excel read UTF-8 correctly)"
115
+ )
116
+ fix.add_argument("--crlf", action="store_true", help="use Windows line endings")
117
+ return parser
118
+
119
+
120
+ def _cmd_check(args: argparse.Namespace) -> int:
121
+ color = _use_color() and args.format == "text"
122
+ exit_code = 0
123
+ results = []
124
+ blocks: List[str] = []
125
+ for path in args.files:
126
+ raw = _read_input(path)
127
+ table = read_table(raw, encoding=args.encoding, delimiter=args.delimiter)
128
+ report = diagnose(table)
129
+ label = "<stdin>" if path == "-" else path
130
+ results.append({"path": label, **report.to_dict()})
131
+ blocks.append(format_report(label, report, color))
132
+ if args.fail_on != "never" and report.at_least(args.fail_on):
133
+ exit_code = 1
134
+ if args.format == "json":
135
+ print(json.dumps({"version": __version__, "files": results}, indent=2, ensure_ascii=False))
136
+ else:
137
+ print("\n\n".join(blocks))
138
+ return exit_code
139
+
140
+
141
+ def _cmd_fix(args: argparse.Namespace) -> int:
142
+ raw = _read_input(args.file)
143
+ repaired = repair(
144
+ raw,
145
+ encoding=args.encoding,
146
+ delimiter=args.delimiter,
147
+ out_delimiter=args.out_delimiter,
148
+ bom=args.bom,
149
+ crlf=args.crlf,
150
+ dedupe=args.dedupe,
151
+ normalize_nulls=args.normalize_nulls,
152
+ neutralize_formulas=args.neutralize_formulas,
153
+ )
154
+
155
+ to_stdout = args.output == "-" or (args.file == "-" and not args.output and not args.in_place)
156
+ if args.in_place and args.file == "-":
157
+ raise CsvDoctorError("--in-place cannot be used with stdin")
158
+ if to_stdout:
159
+ destination = "<stdout>"
160
+ elif args.in_place:
161
+ destination = args.file
162
+ _atomic_write(args.file + ".bak", raw)
163
+ elif args.output:
164
+ destination = args.output
165
+ else:
166
+ stem, ext = os.path.splitext(args.file)
167
+ destination = f"{stem}.fixed{ext or '.csv'}"
168
+
169
+ if to_stdout:
170
+ sys.stdout.buffer.write(repaired.data)
171
+ sys.stdout.buffer.flush()
172
+ else:
173
+ _atomic_write(destination, repaired.data)
174
+
175
+ out = sys.stderr # keep stdout clean for piping
176
+ source = "<stdin>" if args.file == "-" else args.file
177
+ print(f"Fixed {source} -> {destination}", file=out)
178
+ if repaired.actions:
179
+ for action in repaired.actions:
180
+ print(f" - {action}", file=out)
181
+ else:
182
+ print(" - Nothing needed fixing", file=out)
183
+
184
+ # Re-check the result so nothing is claimed that isn't true.
185
+ after = diagnose(
186
+ read_table(repaired.data, encoding="utf-8-sig" if args.bom else "utf-8"),
187
+ )
188
+ remaining = [i for i in after.issues if i.code not in FORMAT_CODES]
189
+ exit_code = 0
190
+ if remaining:
191
+ print(f"\nStill needs attention ({len(remaining)}):", file=out)
192
+ print("(row numbers below refer to the fixed file)", file=out)
193
+ for issue in remaining:
194
+ for line in format_issue(issue, color=False):
195
+ print(line, file=out)
196
+ if any(SEVERITY_RANK[i.severity] >= SEVERITY_RANK["error"] for i in remaining):
197
+ exit_code = 1
198
+ return exit_code
199
+
200
+
201
+ def _harden_streams() -> None:
202
+ """Never crash on a character the terminal cannot display (legacy Windows consoles)."""
203
+ for stream in (sys.stdout, sys.stderr):
204
+ reconfigure = getattr(stream, "reconfigure", None)
205
+ if reconfigure is not None:
206
+ try:
207
+ reconfigure(errors="replace")
208
+ except (ValueError, OSError): # pragma: no cover - exotic stream types
209
+ pass
210
+
211
+
212
+ def main(argv: Optional[Sequence[str]] = None) -> int:
213
+ _harden_streams()
214
+ parser = build_parser()
215
+ args = parser.parse_args(argv)
216
+ try:
217
+ if args.command == "check":
218
+ return _cmd_check(args)
219
+ return _cmd_fix(args)
220
+ except CsvDoctorError as exc:
221
+ print(f"csvdoctor: error: {exc}", file=sys.stderr)
222
+ return 2
223
+ except BrokenPipeError: # e.g. `csvdoctor fix - -o - | head`
224
+ return 0