csv-quality-gate 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,4 @@
1
+ from .models import GateResult, Issue, Severity, Status
2
+ from .validator import validate_csv
3
+
4
+ __all__ = ["GateResult", "Issue", "Severity", "Status", "validate_csv"]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .profiles import PROFILES
8
+ from .report import exit_code, to_json, to_text
9
+ from .validator import validate_csv
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="csv-quality-gate",
15
+ description="Run CSV preflight validation and fail fast before expensive pipeline runs.",
16
+ )
17
+ subparsers = parser.add_subparsers(dest="command", required=True)
18
+
19
+ check = subparsers.add_parser("check", help="run batch CSV quality checks on one file")
20
+ check.add_argument("csv_path", help="path to the csv file to validate")
21
+ check.add_argument(
22
+ "--profile",
23
+ default="generic",
24
+ choices=sorted(PROFILES),
25
+ help="validation profile, for example generic or outreach",
26
+ )
27
+ check.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
28
+ return parser
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ parser = build_parser()
33
+ args = parser.parse_args(argv)
34
+ path = Path(args.csv_path)
35
+ if not path.exists():
36
+ print(f"csv-quality-gate: file not found: {path}", file=sys.stderr)
37
+ return 2
38
+ result = validate_csv(path, profile_name=args.profile)
39
+ print(to_json(result) if args.json else to_text(result))
40
+ return exit_code(result)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class Severity(str, Enum):
8
+ WARNING = "warning"
9
+ ERROR = "error"
10
+
11
+
12
+ class Status(str, Enum):
13
+ PASS = "pass"
14
+ WARN = "warn"
15
+ FAIL = "fail"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Issue:
20
+ severity: Severity
21
+ message: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class GateResult:
26
+ path: str
27
+ profile: str
28
+ row_count: int
29
+ issues: list[Issue]
30
+ status: Status
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Profile:
9
+ name: str
10
+ required_columns: tuple[str, ...]
11
+ critical_columns: tuple[str, ...]
12
+ duplicate_column: str | None
13
+ empty_warning_rate: float
14
+ empty_fail_rate: float
15
+ duplicate_warning_rate: float
16
+ duplicate_fail_rate: float
17
+ suspicious_column: str | None = None
18
+ suspicious_patterns: tuple[str, ...] = ()
19
+
20
+
21
+ OUTREACH_SUSPICIOUS_PATTERNS = (
22
+ r"^.{1,2}$",
23
+ r"^.{50,}$",
24
+ r"^\d+$",
25
+ r"^(the|this|that|these|those)$",
26
+ r"^(however|although|because|before|between)$",
27
+ r"^(january|february|march|april|may|june|july|august|september|october|november|december)$",
28
+ r"^(north|south|east|west|united|states|article|section|chapter)$",
29
+ )
30
+
31
+ PROFILES: dict[str, Profile] = {
32
+ "generic": Profile(
33
+ name="generic",
34
+ required_columns=("company",),
35
+ critical_columns=("company",),
36
+ duplicate_column="company",
37
+ empty_warning_rate=0.10,
38
+ empty_fail_rate=0.30,
39
+ duplicate_warning_rate=0.10,
40
+ duplicate_fail_rate=0.25,
41
+ ),
42
+ "outreach": Profile(
43
+ name="outreach",
44
+ required_columns=("company", "person_name"),
45
+ critical_columns=("company", "person_name"),
46
+ duplicate_column="company",
47
+ empty_warning_rate=0.30,
48
+ empty_fail_rate=0.70,
49
+ duplicate_warning_rate=0.10,
50
+ duplicate_fail_rate=0.25,
51
+ suspicious_column="company",
52
+ suspicious_patterns=OUTREACH_SUSPICIOUS_PATTERNS,
53
+ ),
54
+ }
55
+
56
+
57
+ def get_profile(name: str) -> Profile:
58
+ try:
59
+ return PROFILES[name]
60
+ except KeyError as exc:
61
+ raise ValueError(f"unknown profile: {name}") from exc
62
+
63
+
64
+ def compile_patterns(patterns: tuple[str, ...]) -> list[re.Pattern[str]]:
65
+ return [re.compile(pattern, re.IGNORECASE) for pattern in patterns]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .models import GateResult, Status
6
+
7
+
8
+ def to_text(result: GateResult) -> str:
9
+ lines = [
10
+ f"csv-quality-gate: {result.status.value.upper()}",
11
+ f"file: {result.path}",
12
+ f"profile: {result.profile}",
13
+ f"rows: {result.row_count}",
14
+ ]
15
+ for issue in result.issues:
16
+ lines.append(f" {issue.severity.value.upper()}: {issue.message}")
17
+ return "\n".join(lines)
18
+
19
+
20
+ def to_json(result: GateResult) -> str:
21
+ return json.dumps(
22
+ {
23
+ "path": result.path,
24
+ "profile": result.profile,
25
+ "rows": result.row_count,
26
+ "status": result.status.value,
27
+ "issues": [
28
+ {"severity": issue.severity.value, "message": issue.message}
29
+ for issue in result.issues
30
+ ],
31
+ },
32
+ indent=2,
33
+ )
34
+
35
+
36
+ def exit_code(result: GateResult) -> int:
37
+ return {
38
+ Status.PASS: 0,
39
+ Status.WARN: 1,
40
+ Status.FAIL: 2,
41
+ }[result.status]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from pathlib import Path
5
+
6
+ from .models import GateResult, Issue, Severity, Status
7
+ from .profiles import compile_patterns, get_profile
8
+
9
+
10
+ def validate_csv(path: Path, profile_name: str = "generic") -> GateResult:
11
+ profile = get_profile(profile_name)
12
+ issues: list[Issue] = []
13
+
14
+ with path.open(newline="", encoding="utf-8-sig") as handle:
15
+ rows = list(csv.DictReader(handle))
16
+ headers = tuple((field or "").strip() for field in (rows[0].keys() if rows else ()))
17
+
18
+ missing = [column for column in profile.required_columns if column not in headers]
19
+ for column in missing:
20
+ issues.append(Issue(Severity.ERROR, f"missing required column: {column}"))
21
+
22
+ if not rows:
23
+ issues.append(Issue(Severity.ERROR, "csv has no data rows"))
24
+ return GateResult(str(path), profile.name, 0, issues, Status.FAIL)
25
+
26
+ for column in profile.critical_columns:
27
+ if column not in headers:
28
+ continue
29
+ empty_count = sum(1 for row in rows if not (row.get(column) or "").strip())
30
+ empty_rate = empty_count / len(rows)
31
+ if empty_rate >= profile.empty_fail_rate:
32
+ issues.append(
33
+ Issue(
34
+ Severity.ERROR,
35
+ f"empty rate for {column} is {empty_rate:.0%}, "
36
+ f"exceeds fail threshold {profile.empty_fail_rate:.0%}",
37
+ )
38
+ )
39
+ elif empty_rate >= profile.empty_warning_rate:
40
+ issues.append(
41
+ Issue(
42
+ Severity.WARNING,
43
+ f"empty rate for {column} is {empty_rate:.0%}, "
44
+ f"exceeds warning threshold {profile.empty_warning_rate:.0%}",
45
+ )
46
+ )
47
+
48
+ if profile.duplicate_column and profile.duplicate_column in headers:
49
+ values = [
50
+ (row.get(profile.duplicate_column) or "").strip().casefold()
51
+ for row in rows
52
+ if (row.get(profile.duplicate_column) or "").strip()
53
+ ]
54
+ duplicate_rate = (len(values) - len(set(values))) / len(rows)
55
+ if duplicate_rate >= profile.duplicate_fail_rate:
56
+ issues.append(
57
+ Issue(
58
+ Severity.ERROR,
59
+ f"duplicate rate {duplicate_rate:.0%} exceeds fail threshold "
60
+ f"{profile.duplicate_fail_rate:.0%}",
61
+ )
62
+ )
63
+ elif duplicate_rate >= profile.duplicate_warning_rate:
64
+ issues.append(
65
+ Issue(
66
+ Severity.WARNING,
67
+ f"duplicate rate {duplicate_rate:.0%} exceeds warning threshold "
68
+ f"{profile.duplicate_warning_rate:.0%}",
69
+ )
70
+ )
71
+
72
+ if profile.suspicious_column and profile.suspicious_column in headers:
73
+ patterns = compile_patterns(profile.suspicious_patterns)
74
+ suspicious = []
75
+ for row in rows:
76
+ value = (row.get(profile.suspicious_column) or "").strip()
77
+ if any(pattern.search(value) for pattern in patterns):
78
+ suspicious.append(value)
79
+ suspicious_rate = len(suspicious) / len(rows)
80
+ if suspicious_rate >= 0.30:
81
+ issues.append(
82
+ Issue(
83
+ Severity.ERROR,
84
+ f"suspicious {profile.suspicious_column} rate is {suspicious_rate:.0%}",
85
+ )
86
+ )
87
+ elif suspicious_rate >= 0.10:
88
+ issues.append(
89
+ Issue(
90
+ Severity.WARNING,
91
+ f"suspicious {profile.suspicious_column} rate is {suspicious_rate:.0%}",
92
+ )
93
+ )
94
+
95
+ status = _status_for_issues(issues)
96
+ return GateResult(str(path), profile.name, len(rows), issues, status)
97
+
98
+
99
+ def _status_for_issues(issues: list[Issue]) -> Status:
100
+ if any(issue.severity is Severity.ERROR for issue in issues):
101
+ return Status.FAIL
102
+ if issues:
103
+ return Status.WARN
104
+ return Status.PASS
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: csv-quality-gate
3
+ Version: 0.1.0
4
+ Summary: CSV preflight validation and batch CSV quality checks that fail fast before pipeline runs.
5
+ Author: Rolando Bosch
6
+ License: MIT
7
+ Keywords: csv preflight validation,batch CSV quality checks,fail fast,pipeline validation,csv lint
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8.0; extra == "dev"
18
+ Requires-Dist: ruff>=0.5; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # csv-quality-gate: CSV preflight validation before pipeline runs
22
+
23
+ Fail fast before pipeline runs when the input CSV is broken, incomplete, duplicated, or obviously junk.
24
+
25
+ `csv-quality-gate` runs batch CSV quality checks and returns `pass`, `warn`, or `fail` before expensive pipeline steps burn time on bad input.
26
+
27
+ - "We keep running expensive pipeline steps on broken CSVs."
28
+ - "A batch run fails 20 minutes in because the input CSV was junk."
29
+ - "We only discover missing required columns after the job already started."
30
+ - "Duplicate rows and empty contact fields keep polluting our batch runs."
31
+ - "I want CSV preflight validation, not a whole data platform."
32
+
33
+ Fastest install:
34
+
35
+ ```bash
36
+ pip install csv-quality-gate
37
+ ```
38
+
39
+ Fastest real usage:
40
+
41
+ ```bash
42
+ csv-quality-gate check leads.csv --profile outreach
43
+ ```
44
+
45
+ Exact outcome:
46
+
47
+ ```text
48
+ csv-quality-gate: FAIL
49
+ file: leads.csv
50
+ profile: outreach
51
+ rows: 125
52
+ ERROR: missing required column: person_name
53
+ WARNING: duplicate rate 12% exceeds warning threshold 10%
54
+ ```
55
+
56
+ ![csv-quality-gate preview](assets/preview.png)
57
+
58
+ It is designed for narrow, honest use as a preflight gate, not as a full data quality platform.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ pip install csv-quality-gate
64
+ ```
65
+
66
+ For development:
67
+
68
+ ```bash
69
+ pip install -e ".[dev]"
70
+ ```
71
+
72
+ ## Common search-intent use cases
73
+
74
+ - CSV preflight validation
75
+ - batch CSV quality checks
76
+ - fail fast before pipeline runs
77
+ - CSV validation before ETL or enrichment
78
+ - detect junk CSV rows before batch jobs
79
+
80
+ ## Usage
81
+
82
+ ```bash
83
+ csv-quality-gate check leads.csv
84
+ csv-quality-gate check leads.csv --profile outreach
85
+ csv-quality-gate check leads.csv --profile generic --json
86
+ ```
87
+
88
+ Exit codes:
89
+
90
+ - `0` pass
91
+ - `1` warnings only
92
+ - `2` fail
93
+
94
+ ## Profiles
95
+
96
+ Built-in profiles:
97
+
98
+ - `generic`
99
+ - validates required columns, empties, duplicates, empty file
100
+ - `outreach`
101
+ - adds suspicious company-name heuristics for GTM/contact pipelines
102
+
103
+ ## Output
104
+
105
+ ```text
106
+ csv-quality-gate: FAIL
107
+ file: leads.csv
108
+ profile: outreach
109
+ rows: 125
110
+ ERROR: missing required column: person_name
111
+ WARNING: duplicate rate 12% exceeds warning threshold 10%
112
+ ```
113
+
114
+ ## JSON mode
115
+
116
+ ```bash
117
+ csv-quality-gate check leads.csv --json
118
+ ```
119
+
120
+ ## Limitations
121
+
122
+ - Heuristics are intentionally simple.
123
+ - The `outreach` profile is opinionated and should not be treated as universal truth.
124
+ - The tool validates shape and obvious noise, not semantic correctness.
125
+
126
+ ## When To Use It
127
+
128
+ - Before enrichment, outreach, ETL, or batch scoring runs
129
+ - In CI for checked-in CSV inputs
130
+ - As a preflight gate before expensive pipeline work
131
+
132
+ ## When Not To Use It
133
+
134
+ - When you need semantic validation of the data itself
135
+ - When your input is not CSV
136
+ - When you need a full data quality framework with lineage and profiling
137
+
138
+ ## More From Hermes Labs
139
+
140
+ - [intent-verify](https://github.com/roli-lpci/intent-verify): repo intent verification and spec drift checks
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ ruff check .
146
+ python3 -m pytest -q
147
+ python3 -m py_compile src/csv_quality_gate/*.py
148
+ ```
@@ -0,0 +1,12 @@
1
+ csv_quality_gate/__init__.py,sha256=Eii7WRh8OI_DYXVgHB_SbSROmvppFSSKi1Nk1yXgub0,165
2
+ csv_quality_gate/cli.py,sha256=1IsK4-SScaCc3NFMEU4up18QsHKQhHkEx8NfcDF5Qpo,1431
3
+ csv_quality_gate/models.py,sha256=_jO9m6J55yKfaGBFGu1xMSYh1TximawxjgmIMdXnf5g,462
4
+ csv_quality_gate/profiles.py,sha256=EMYL3J_gKr-MRToUtSwV-ypw32uWKoVuN7idmV_rtCA,1883
5
+ csv_quality_gate/report.py,sha256=zcye6IMSlJJYxSa4HpikgFxAHLFrYQ-bE6Y25fji_dY,1024
6
+ csv_quality_gate/validator.py,sha256=RcYwxuigKdrBXEQz4B2JvXTEHLGIRRNrftMPuMYosXY,3996
7
+ csv_quality_gate-0.1.0.dist-info/licenses/LICENSE,sha256=VOg1xj-037ncqAL2LUPDVkcLp4gGdk9nrYRx6YPMfvw,1070
8
+ csv_quality_gate-0.1.0.dist-info/METADATA,sha256=LqiROao0wpXg0nvoRjIovVRA06RHfDjVLy9QM3tQBLs,3719
9
+ csv_quality_gate-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ csv_quality_gate-0.1.0.dist-info/entry_points.txt,sha256=-ByTcV6TYrblyxw247qdjOFhdLrOSZPpXPl5xRvD1LA,63
11
+ csv_quality_gate-0.1.0.dist-info/top_level.txt,sha256=j-y9EbI-uMDi4LrsMs9YI9JFPc_PxdNE9CUx8NZL4sw,17
12
+ csv_quality_gate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ csv-quality-gate = csv_quality_gate.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rolando Bosch
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
+ csv_quality_gate