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.
@@ -0,0 +1,5 @@
1
+ """Local CSV validation; values remain strings and input is never modified."""
2
+ from .validate import validate_csv
3
+
4
+ __all__ = ["validate_csv"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shipit-csv-validate = shipit_csv_validator.cli:main
@@ -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