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 +22 -0
- csvdoctor/__main__.py +4 -0
- csvdoctor/cli.py +224 -0
- csvdoctor/diagnose.py +592 -0
- csvdoctor/fix.py +250 -0
- csvdoctor/py.typed +0 -0
- csvdoctor/reader.py +174 -0
- csvdoctor/report.py +75 -0
- csvdoctor/textutil.py +186 -0
- csvdoctor-0.1.0.dist-info/METADATA +272 -0
- csvdoctor-0.1.0.dist-info/RECORD +15 -0
- csvdoctor-0.1.0.dist-info/WHEEL +5 -0
- csvdoctor-0.1.0.dist-info/entry_points.txt +2 -0
- csvdoctor-0.1.0.dist-info/licenses/LICENSE +21 -0
- csvdoctor-0.1.0.dist-info/top_level.txt +1 -0
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
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
|