shipit-csv-validator 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.
- shipit_csv_validator/__init__.py +5 -0
- shipit_csv_validator/__main__.py +3 -0
- shipit_csv_validator/cli.py +106 -0
- shipit_csv_validator/validate.py +157 -0
- shipit_csv_validator-0.1.0.dist-info/METADATA +67 -0
- shipit_csv_validator-0.1.0.dist-info/RECORD +10 -0
- shipit_csv_validator-0.1.0.dist-info/WHEEL +5 -0
- shipit_csv_validator-0.1.0.dist-info/entry_points.txt +2 -0
- shipit_csv_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
- shipit_csv_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Bounded UTF-8 CSV command-line input; never writes the source file."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .validate import _units, validate_csv
|
|
8
|
+
|
|
9
|
+
LIMIT = 32 * 1024 * 1024
|
|
10
|
+
HELP = """Usage: shipit-csv-validate [options] [FILE|-]
|
|
11
|
+
Validate a UTF-8 CSV file or standard input without changing its contents.
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--json Print a stable JSON report
|
|
15
|
+
--delimiter CHAR Use one delimiter character (use "tab" for a tab)
|
|
16
|
+
--no-header Treat the first record as data
|
|
17
|
+
--max-errors N Maximum reported diagnostics (1-10000; default 100)
|
|
18
|
+
--help Show this help
|
|
19
|
+
--version Show the version
|
|
20
|
+
-- End options; allows filenames starting with a dash
|
|
21
|
+
|
|
22
|
+
Exit codes: 0 valid, 1 invalid CSV, 2 usage or input error.
|
|
23
|
+
Limit: buffered UTF-8 input, up to 32 MiB. No type/schema inference.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fail(json_mode, code, message):
|
|
28
|
+
if json_mode:
|
|
29
|
+
print(json.dumps({"valid": False, "error": {"code": code, "message": message}},
|
|
30
|
+
ensure_ascii=True, separators=(",", ":")))
|
|
31
|
+
else:
|
|
32
|
+
print(message, file=sys.stderr)
|
|
33
|
+
return 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv=None):
|
|
37
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
38
|
+
boundary = args.index("--") if "--" in args else len(args)
|
|
39
|
+
json_mode = "--json" in args[:boundary]
|
|
40
|
+
delimiter, header, max_errors, filename = ",", True, 100, None
|
|
41
|
+
end_options = False
|
|
42
|
+
index = 0
|
|
43
|
+
try:
|
|
44
|
+
while index < len(args):
|
|
45
|
+
argument = args[index]
|
|
46
|
+
index += 1
|
|
47
|
+
if not end_options and argument == "--":
|
|
48
|
+
end_options = True
|
|
49
|
+
continue
|
|
50
|
+
if not end_options and argument == "--json":
|
|
51
|
+
continue
|
|
52
|
+
if not end_options and argument == "--help":
|
|
53
|
+
print(HELP, end="")
|
|
54
|
+
return 0
|
|
55
|
+
if not end_options and argument == "--version":
|
|
56
|
+
print(__version__)
|
|
57
|
+
return 0
|
|
58
|
+
if not end_options and argument == "--no-header":
|
|
59
|
+
header = False
|
|
60
|
+
continue
|
|
61
|
+
if not end_options and argument in ("--delimiter", "--max-errors"):
|
|
62
|
+
raw = args[index] if index < len(args) else None
|
|
63
|
+
index += 1
|
|
64
|
+
if argument == "--delimiter":
|
|
65
|
+
delimiter = "\t" if raw == "tab" else raw
|
|
66
|
+
if (not isinstance(delimiter, str) or len(_units(delimiter)) != 1
|
|
67
|
+
or delimiter in ('"', "\r", "\n", "\0")):
|
|
68
|
+
raise TypeError("Delimiter must be one character other than quote, newline or NUL")
|
|
69
|
+
else:
|
|
70
|
+
if raw is None or len(raw) > 5 or not re.fullmatch(r"[1-9][0-9]*", raw) or int(raw) > 10000:
|
|
71
|
+
raise TypeError("Maximum errors must be 1-10000")
|
|
72
|
+
max_errors = int(raw)
|
|
73
|
+
continue
|
|
74
|
+
if not end_options and argument.startswith("-") and argument != "-":
|
|
75
|
+
raise TypeError("Unknown option; use --help")
|
|
76
|
+
if filename is not None:
|
|
77
|
+
raise TypeError("Provide one file, or standard input")
|
|
78
|
+
filename = argument
|
|
79
|
+
except TypeError as error:
|
|
80
|
+
return _fail(json_mode, "USAGE", str(error))
|
|
81
|
+
try:
|
|
82
|
+
if filename is None or filename == "-":
|
|
83
|
+
data = sys.stdin.buffer.read(LIMIT + 1)
|
|
84
|
+
else:
|
|
85
|
+
with open(filename, "rb") as source:
|
|
86
|
+
data = source.read(LIMIT + 1)
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return _fail(json_mode, "INPUT_READ", "Could not read input")
|
|
89
|
+
if len(data) > LIMIT:
|
|
90
|
+
return _fail(json_mode, "INPUT_LIMIT", "Input exceeds the 32 MiB limit")
|
|
91
|
+
try:
|
|
92
|
+
text = data.decode("utf-8", "strict")
|
|
93
|
+
except UnicodeDecodeError:
|
|
94
|
+
return _fail(json_mode, "INPUT_ENCODING", "Input must be valid UTF-8")
|
|
95
|
+
report = validate_csv(text, delimiter=delimiter, header=header, max_errors=max_errors)
|
|
96
|
+
if json_mode:
|
|
97
|
+
print(json.dumps(report, ensure_ascii=True, separators=(",", ":")))
|
|
98
|
+
elif report["valid"]:
|
|
99
|
+
print(f'Valid CSV: {report["dataRecords"]} data records, {report["columns"]} columns')
|
|
100
|
+
else:
|
|
101
|
+
for issue in report["diagnostics"]:
|
|
102
|
+
print(f'{issue["code"]}: record {issue["record"]}, field {issue["field"]}, '
|
|
103
|
+
f'line {issue["line"]}, column {issue["column"]}: {issue["message"]}')
|
|
104
|
+
if report["diagnosticsTruncated"]:
|
|
105
|
+
print("Additional diagnostics omitted")
|
|
106
|
+
return 0 if report["valid"] else 1
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""CSV conformance v1 without type inference or external dependencies."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _units(text: str) -> str:
|
|
5
|
+
"""Use UTF-16 units for the cross-language delimiter/column contract."""
|
|
6
|
+
data = text.encode("utf-16-le", "surrogatepass")
|
|
7
|
+
return "".join(chr(data[i] | data[i + 1] << 8) for i in range(0, len(data), 2))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_csv(
|
|
11
|
+
text: str, *, delimiter: str = ",", header: bool = True, max_errors: int = 100
|
|
12
|
+
) -> dict[str, object]:
|
|
13
|
+
"""Validate complete CSV text and return the shared JSON-compatible report.
|
|
14
|
+
|
|
15
|
+
Positions are one-based UTF-16 columns. The API has no byte limit; the CLI
|
|
16
|
+
bounds input at 32 MiB. No filesystem, network, environment or cell execution
|
|
17
|
+
occurs. Invalid arguments raise TypeError.
|
|
18
|
+
"""
|
|
19
|
+
if not isinstance(text, str):
|
|
20
|
+
raise TypeError("Input must be a string")
|
|
21
|
+
if (
|
|
22
|
+
not isinstance(delimiter, str)
|
|
23
|
+
or len(_units(delimiter)) != 1
|
|
24
|
+
or delimiter in ('"', "\r", "\n", "\0")
|
|
25
|
+
):
|
|
26
|
+
raise TypeError("Delimiter must be one character other than quote, newline or NUL")
|
|
27
|
+
if type(header) is not bool or type(max_errors) is not int or not 1 <= max_errors <= 10000:
|
|
28
|
+
raise TypeError("Invalid validation options")
|
|
29
|
+
source = _units(text[1:] if text.startswith("\ufeff") else text)
|
|
30
|
+
delimiter = _units(delimiter)
|
|
31
|
+
rows = []
|
|
32
|
+
diagnostics = []
|
|
33
|
+
truncated = False
|
|
34
|
+
record = line = column = 1
|
|
35
|
+
row_start = (line, column)
|
|
36
|
+
field_start = (line, column)
|
|
37
|
+
fields = []
|
|
38
|
+
value = []
|
|
39
|
+
state = "start"
|
|
40
|
+
row_touched = fatal = False
|
|
41
|
+
|
|
42
|
+
def error(code, message, at=None, field=None, row_number=None):
|
|
43
|
+
nonlocal truncated
|
|
44
|
+
if len(diagnostics) < max_errors:
|
|
45
|
+
at_line, at_column = (line, column) if at is None else at
|
|
46
|
+
diagnostics.append({
|
|
47
|
+
"code": code, "message": message,
|
|
48
|
+
"record": record if row_number is None else row_number,
|
|
49
|
+
"field": len(fields) + 1 if field is None else field,
|
|
50
|
+
"line": at_line, "column": at_column,
|
|
51
|
+
})
|
|
52
|
+
else:
|
|
53
|
+
truncated = True
|
|
54
|
+
|
|
55
|
+
def finish_field():
|
|
56
|
+
nonlocal value, state
|
|
57
|
+
fields.append(("".join(value), field_start))
|
|
58
|
+
value = []
|
|
59
|
+
state = "start"
|
|
60
|
+
|
|
61
|
+
def finish_record():
|
|
62
|
+
nonlocal fields, record, row_touched
|
|
63
|
+
finish_field()
|
|
64
|
+
rows.append((fields, row_start))
|
|
65
|
+
fields = []
|
|
66
|
+
record += 1
|
|
67
|
+
row_touched = False
|
|
68
|
+
|
|
69
|
+
index = 0
|
|
70
|
+
while index < len(source):
|
|
71
|
+
character = source[index]
|
|
72
|
+
newline = character in ("\r", "\n")
|
|
73
|
+
crlf = character == "\r" and source[index + 1:index + 2] == "\n"
|
|
74
|
+
if character == "\0":
|
|
75
|
+
error("NUL_CHARACTER", "NUL characters are not supported")
|
|
76
|
+
fatal = True
|
|
77
|
+
break
|
|
78
|
+
if state == "quoted":
|
|
79
|
+
row_touched = True
|
|
80
|
+
if character == '"':
|
|
81
|
+
if source[index + 1:index + 2] == '"':
|
|
82
|
+
value.append('"')
|
|
83
|
+
index += 2
|
|
84
|
+
column += 2
|
|
85
|
+
continue
|
|
86
|
+
state = "closed"
|
|
87
|
+
else:
|
|
88
|
+
value.append(character)
|
|
89
|
+
if crlf:
|
|
90
|
+
value.append("\n")
|
|
91
|
+
index += 1
|
|
92
|
+
if newline:
|
|
93
|
+
line += 1
|
|
94
|
+
column = 1
|
|
95
|
+
else:
|
|
96
|
+
column += 1
|
|
97
|
+
index += 1
|
|
98
|
+
continue
|
|
99
|
+
if character == delimiter:
|
|
100
|
+
row_touched = True
|
|
101
|
+
finish_field()
|
|
102
|
+
column += 1
|
|
103
|
+
field_start = (line, column)
|
|
104
|
+
index += 1
|
|
105
|
+
continue
|
|
106
|
+
if newline:
|
|
107
|
+
finish_record()
|
|
108
|
+
if crlf:
|
|
109
|
+
index += 1
|
|
110
|
+
line += 1
|
|
111
|
+
column = 1
|
|
112
|
+
row_start = field_start = (line, column)
|
|
113
|
+
index += 1
|
|
114
|
+
continue
|
|
115
|
+
if state == "closed":
|
|
116
|
+
error("TEXT_AFTER_QUOTE", "Only a delimiter or line ending may follow a closing quote")
|
|
117
|
+
fatal = True
|
|
118
|
+
break
|
|
119
|
+
if character == '"':
|
|
120
|
+
if state != "start":
|
|
121
|
+
error("QUOTE_IN_UNQUOTED_FIELD", "A quote must start at the beginning of a field")
|
|
122
|
+
fatal = True
|
|
123
|
+
break
|
|
124
|
+
state = "quoted"
|
|
125
|
+
else:
|
|
126
|
+
value.append(character)
|
|
127
|
+
state = "plain"
|
|
128
|
+
row_touched = True
|
|
129
|
+
column += 1
|
|
130
|
+
index += 1
|
|
131
|
+
if not fatal and state == "quoted":
|
|
132
|
+
error("UNCLOSED_QUOTE", "Quoted field is missing a closing quote", field_start)
|
|
133
|
+
fatal = True
|
|
134
|
+
if not fatal and (row_touched or fields or value):
|
|
135
|
+
finish_record()
|
|
136
|
+
width = len(rows[0][0]) if rows else 0
|
|
137
|
+
if not source:
|
|
138
|
+
error("EMPTY_INPUT", "Input contains no records", (1, 1), 1, 1)
|
|
139
|
+
if header and rows:
|
|
140
|
+
seen = {}
|
|
141
|
+
for index, (field_value, at) in enumerate(rows[0][0], 1):
|
|
142
|
+
if not field_value:
|
|
143
|
+
error("EMPTY_HEADER", "Header name is empty", at, index, 1)
|
|
144
|
+
if field_value in seen:
|
|
145
|
+
error("DUPLICATE_HEADER", f"Header repeats field {seen[field_value]}", at, index, 1)
|
|
146
|
+
else:
|
|
147
|
+
seen[field_value] = index
|
|
148
|
+
for index, (row_fields, at) in enumerate(rows, 1):
|
|
149
|
+
if len(row_fields) != width:
|
|
150
|
+
error("ROW_WIDTH", f"Expected {width} fields; found {len(row_fields)}",
|
|
151
|
+
at, min(width, len(row_fields)) + 1, index)
|
|
152
|
+
return {
|
|
153
|
+
"valid": not diagnostics, "records": len(rows),
|
|
154
|
+
"dataRecords": max(0, len(rows) - (1 if header and rows else 0)),
|
|
155
|
+
"columns": width, "header": header, "diagnostics": diagnostics,
|
|
156
|
+
"diagnosticsTruncated": truncated, "parseComplete": not fatal,
|
|
157
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shipit-csv-validator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-free local CSV validation with record, field and source diagnostics
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://csvfile.tools/validate
|
|
7
|
+
Project-URL: Repository, https://github.com/shipit-software/csv-validator
|
|
8
|
+
Project-URL: Issues, https://github.com/shipit-software/csv-validator/issues
|
|
9
|
+
Keywords: csv,validation,cli
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# CSV Validator for Python
|
|
16
|
+
|
|
17
|
+
Validate UTF-8 CSV locally with record, field, line and column diagnostics.
|
|
18
|
+
This is the Python implementation from the same
|
|
19
|
+
[CSV Validator repository](https://github.com/shipit-software/csv-validator)
|
|
20
|
+
as the JavaScript and Rust implementations. It has no runtime dependencies,
|
|
21
|
+
network calls, type guessing, cell evaluation or automatic edits.
|
|
22
|
+
|
|
23
|
+
For an interactive workflow, use the [CSV validator](https://csvfile.tools/validate).
|
|
24
|
+
|
|
25
|
+
Requires Python 3.11 or newer. Install an available released version with:
|
|
26
|
+
|
|
27
|
+
python -m pip install shipit-csv-validator
|
|
28
|
+
|
|
29
|
+
For a local source checkout, install the python directory instead.
|
|
30
|
+
|
|
31
|
+
python -m pip install ./python
|
|
32
|
+
shipit-csv-validate --json data.csv
|
|
33
|
+
python -m shipit_csv_validator --delimiter tab --no-header data.tsv
|
|
34
|
+
shipit-csv-validate --json -- -starts-with-dash.csv
|
|
35
|
+
|
|
36
|
+
Omit the filename or use - to read standard input. Exit codes are 0 for valid
|
|
37
|
+
CSV, 1 for invalid CSV and 2 for usage, read, encoding or input-limit errors.
|
|
38
|
+
The CLI buffers at most 32 MiB plus one byte for detecting overflow. Files are
|
|
39
|
+
opened read-only; it never changes input files. --help lists all options.
|
|
40
|
+
|
|
41
|
+
from shipit_csv_validator import validate_csv
|
|
42
|
+
|
|
43
|
+
report = validate_csv("id,name\n001,Ada")
|
|
44
|
+
assert report["valid"]
|
|
45
|
+
assert report["dataRecords"] == 1
|
|
46
|
+
|
|
47
|
+
The API accepts complete text and keyword-only delimiter, header and max_errors
|
|
48
|
+
options. Invalid options raise TypeError. API input is not limited by byte size.
|
|
49
|
+
The returned dictionary uses the shared JSON report keys: valid, records,
|
|
50
|
+
dataRecords, columns, header, diagnostics, diagnosticsTruncated and parseComplete.
|
|
51
|
+
|
|
52
|
+
Checks include row widths, empty/duplicate exact header names, quote placement,
|
|
53
|
+
unclosed quoted fields and NUL characters. Quoted delimiters/line endings,
|
|
54
|
+
escaped quotes, an initial UTF-8 BOM and CRLF/LF/CR are supported. Spaces are
|
|
55
|
+
significant. Header names are case-sensitive. Empty lines are records.
|
|
56
|
+
Syntax errors stop parsing; counts then include completed records only.
|
|
57
|
+
Positions are one-based, and columns count UTF-16 units for parity with the
|
|
58
|
+
other implementations. A delimiter must be exactly one UTF-16 unit other than
|
|
59
|
+
quote, CR, LF or NUL. Formula safety, application schemas, types, required values
|
|
60
|
+
and domain-specific rules are outside this validator.
|
|
61
|
+
|
|
62
|
+
From the repository root, run the shared 22-case corpus and additional CLI/API
|
|
63
|
+
checks with:
|
|
64
|
+
|
|
65
|
+
PYTHONPATH=python/src python -m unittest discover -s python/tests -v
|
|
66
|
+
|
|
67
|
+
All fixtures are synthetic. Code is MIT licensed; see LICENSE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
shipit_csv_validator/__init__.py,sha256=JSOjlb0bbCtgfZMSO9UDV_vsFqh47FKlyvbTrlNpA3s,164
|
|
2
|
+
shipit_csv_validator/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
shipit_csv_validator/cli.py,sha256=YaVUa2L0ouzzUWEjczXh6enoYSXEQD5J43wECbqdRRI,4546
|
|
4
|
+
shipit_csv_validator/validate.py,sha256=eQWe5YJTkoEH417r5CWIrGoTNTpfEgV29CsktUC0qok,5725
|
|
5
|
+
shipit_csv_validator-0.1.0.dist-info/licenses/LICENSE,sha256=E_rh7CMJg1XonO5IlKk75XytR9tMhfYl_CmV9kdSB2w,1072
|
|
6
|
+
shipit_csv_validator-0.1.0.dist-info/METADATA,sha256=ZmldLdTB5lWAH7o83-OKNvwT3MaMq9fWWJfUrr0hZi0,3063
|
|
7
|
+
shipit_csv_validator-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
shipit_csv_validator-0.1.0.dist-info/entry_points.txt,sha256=HDbcvQo_JBnMuZgTKMsKv9HUdjgVfsngK4bDTXaNq6A,70
|
|
9
|
+
shipit_csv_validator-0.1.0.dist-info/top_level.txt,sha256=GWrPJaB8U4AIvrjds_SxTqp8PgTKt_HkCHAS51qtXqA,21
|
|
10
|
+
shipit_csv_validator-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shipit Software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
shipit_csv_validator
|