rowcanary 1.0.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.
rowcanary/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Detect silent changes in imported and exported data using control samples."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .core import PROBES, ProbeFileError, Report, check_file, write_probes
6
+ from .core import ID_COLUMN, VALUE_COLUMN, Issue, Probe
7
+ from .config import Config, load_config
8
+ from .probes import select_probes
9
+ from .reporting import html_report
10
+
11
+ __all__ = [
12
+ "PROBES",
13
+ "ID_COLUMN",
14
+ "VALUE_COLUMN",
15
+ "ProbeFileError",
16
+ "Probe",
17
+ "Issue",
18
+ "Report",
19
+ "Config",
20
+ "load_config",
21
+ "select_probes",
22
+ "html_report",
23
+ "check_file",
24
+ "write_probes",
25
+ ]
rowcanary/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
rowcanary/cli.py ADDED
@@ -0,0 +1,201 @@
1
+ """Human-friendly command-line interface for RowCanary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+ from dataclasses import fields, replace
12
+ from typing import TextIO
13
+
14
+ from . import __version__
15
+ from .core import ProbeFileError, check_file, write_probes, ID_COLUMN, VALUE_COLUMN
16
+ from .config import Config, load_config
17
+ from .formats import FORMATS
18
+ from .reporting import export_report
19
+
20
+
21
+ def _print_human(message: str, *, file: TextIO | None = None) -> None:
22
+ """Keep untrusted text on one line and represent unsupported characters."""
23
+ stream = file if file is not None else sys.stdout
24
+ display = "".join(
25
+ character if character.isprintable() else character.encode("unicode_escape").decode("ascii")
26
+ for character in message
27
+ )
28
+ encoding = getattr(stream, "encoding", None) or "utf-8"
29
+ print(display.encode(encoding, errors="backslashreplace").decode(encoding), file=stream)
30
+
31
+
32
+ def render_report(report, *, output_json: bool = False) -> None:
33
+ if output_json:
34
+ print(json.dumps(report.as_dict(), ensure_ascii=True, indent=2))
35
+ return
36
+ result = "PASS" if report.ok else "FAIL"
37
+ _print_human(f"{result} {report.passed}/{report.total} test values preserved")
38
+ if report.issues:
39
+ for issue in report.issues:
40
+ _print_human(f" [{issue.code}] {issue.ident}: {issue.reason}")
41
+ if issue.expected is not None:
42
+ _print_human(f" expected: {issue.expected!r}")
43
+ if issue.actual is not None:
44
+ _print_human(f" actual: {issue.actual!r}")
45
+ else:
46
+ _print_human(" No changes detected in the test values.")
47
+ for observation in report.observations:
48
+ _print_human(f" [{observation.severity}:{observation.code}] {observation.reason}")
49
+ _print_human(" Scope: only the provided control samples, not an entire real dataset.")
50
+
51
+
52
+ def demo() -> int:
53
+ with tempfile.TemporaryDirectory(prefix="rowcanary-") as directory:
54
+ sample = Path(directory) / "probe.csv"
55
+ altered = Path(directory) / "altered.csv"
56
+ write_probes(sample)
57
+ with sample.open(newline="", encoding="utf-8") as stream:
58
+ rows = list(csv.DictReader(stream))
59
+ for row in rows:
60
+ if row[ID_COLUMN] == "zeroes":
61
+ row[VALUE_COLUMN] = "12345"
62
+ elif row[ID_COLUMN] == "scientific":
63
+ row[VALUE_COLUMN] = "5000000000000"
64
+ elif row[ID_COLUMN] == "formula":
65
+ row[VALUE_COLUMN] = "2"
66
+ with altered.open("w", newline="", encoding="utf-8") as stream:
67
+ writer = csv.DictWriter(stream, fieldnames=[ID_COLUMN, VALUE_COLUMN])
68
+ writer.writeheader()
69
+ writer.writerows(rows)
70
+ print("DEMO: three sample values were intentionally modified.")
71
+ render_report(check_file(altered))
72
+ print("Use 'rowcanary make probe.csv' to test a real import/export process.")
73
+ return 0
74
+
75
+
76
+ def main(argv: list[str] | None = None) -> int:
77
+ parser = argparse.ArgumentParser(
78
+ prog="rowcanary", description="Test whether an import/export process silently changes data."
79
+ )
80
+ parser.add_argument("--version", action="version", version=f"rowcanary {__version__}")
81
+ sub = parser.add_subparsers(dest="command", required=True)
82
+ maker = sub.add_parser("make", help="Generate a deterministic file of edge-case test values")
83
+ maker.add_argument("output", type=Path, nargs="?", default=Path("rowcanary-probe.csv"))
84
+ maker.add_argument(
85
+ "--delimiter", default=argparse.SUPPRESS, help="CSV field separator (default: comma)"
86
+ )
87
+ checker = sub.add_parser("check", help="Audit an exported control file")
88
+ checker.add_argument("input", type=Path)
89
+ batch = sub.add_parser("batch", help="Audit several exports and retain every result")
90
+ batch.add_argument("inputs", type=Path, nargs="+")
91
+ for command in (maker, checker, batch):
92
+ command.add_argument("--config", type=Path, help="Reusable JSON configuration file")
93
+ command.add_argument(
94
+ "--format", dest="file_format", choices=("auto", *FORMATS), default=argparse.SUPPRESS
95
+ )
96
+ command.add_argument(
97
+ "--encoding",
98
+ default=argparse.SUPPRESS,
99
+ help="Text encoding (BOM encodings detected when checking)",
100
+ )
101
+ command.add_argument(
102
+ "--probe-set",
103
+ default=argparse.SUPPRESS,
104
+ help="default, extended, none, or comma-separated categories",
105
+ )
106
+ command.add_argument(
107
+ "--custom-values",
108
+ type=Path,
109
+ default=argparse.SUPPRESS,
110
+ help="JSON array of custom {id, value} controls",
111
+ )
112
+ for command in (checker, batch):
113
+ command.add_argument("--id-column", default=argparse.SUPPRESS)
114
+ command.add_argument("--value-column", default=argparse.SUPPRESS)
115
+ command.add_argument(
116
+ "--delimiter", default=argparse.SUPPRESS, help="CSV field separator (default: comma)"
117
+ )
118
+ command.add_argument(
119
+ "--json", action="store_true", help="Print a machine-readable JSON report"
120
+ )
121
+ command.add_argument(
122
+ "--strict-order",
123
+ action=argparse.BooleanOptionalAction,
124
+ default=argparse.SUPPRESS,
125
+ help="Treat reordered rows as an integrity difference",
126
+ )
127
+ command.add_argument(
128
+ "--report", type=Path, help="Create a new standalone .html or .json report"
129
+ )
130
+ sub.add_parser("demo", help="Run a self-contained example with intentionally broken values")
131
+ server = sub.add_parser("serve", help="Open the local-only browser interface")
132
+ server.add_argument("--port", type=int, default=8765)
133
+ server.add_argument("--open", action="store_true", help="Open the local URL in your browser")
134
+ args = parser.parse_args(argv)
135
+ try:
136
+ if args.command == "serve":
137
+ from .web import serve
138
+
139
+ return serve(port=args.port, open_browser=args.open)
140
+ if args.command in ("make", "check", "batch"):
141
+ config = load_config(args.config) if args.config else Config()
142
+ overrides = {
143
+ f.name: getattr(args, f.name) for f in fields(Config) if hasattr(args, f.name)
144
+ }
145
+ config = replace(config, **overrides)
146
+ if args.command == "make":
147
+ options = config.make_options()
148
+ probes = options["probes"]
149
+ write_probes(args.output, **options)
150
+ _print_human(f"Created {args.output} with {len(probes)} control rows.")
151
+ _print_human("Import and export it, then run: rowcanary check <exported.csv>")
152
+ if config.delimiter != ",":
153
+ _print_human("Use the same --delimiter when checking the exported CSV.")
154
+ _print_human(
155
+ "WARNING: Values beginning with '=', '+', or '-' may be evaluated "
156
+ "by spreadsheet software; use only in isolated test data."
157
+ )
158
+ return 0
159
+ if args.command in ("check", "batch"):
160
+ options = config.check_options()
161
+ if args.command == "check":
162
+ report = check_file(args.input, **options)
163
+ if args.report:
164
+ export_report(args.report, [report])
165
+ render_report(report, output_json=args.json)
166
+ return 0 if report.ok else 1
167
+ results = []
168
+ status = 0
169
+ for path in args.inputs:
170
+ try:
171
+ report = check_file(path, **options)
172
+ results.append(report.as_dict())
173
+ status = max(status, 0 if report.ok else 1)
174
+ if not args.json:
175
+ _print_human(str(path))
176
+ render_report(report)
177
+ except (ProbeFileError, OSError) as exc:
178
+ results.append({"input": str(path), "error": str(exc)})
179
+ status = 2
180
+ if not args.json:
181
+ _print_human(f"{path}: {exc}", file=sys.stderr)
182
+ if args.report:
183
+ export_report(args.report, results, batch=True)
184
+ if args.json:
185
+ print(
186
+ json.dumps(
187
+ {"schema_version": "1.0", "results": results}, ensure_ascii=True, indent=2
188
+ )
189
+ )
190
+ return status
191
+ return demo()
192
+ except (ProbeFileError, OSError) as exc:
193
+ if args.command in ("check", "batch") and args.json:
194
+ print(json.dumps({"error": str(exc)}, ensure_ascii=True, indent=2))
195
+ else:
196
+ _print_human(f"rowcanary: {exc}", file=sys.stderr)
197
+ return 2
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main())
rowcanary/config.py ADDED
@@ -0,0 +1,110 @@
1
+ """Validated, immutable options shared by Python and command-line integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import json
7
+ from dataclasses import dataclass, fields
8
+ from pathlib import Path
9
+
10
+ from .core import ID_COLUMN, VALUE_COLUMN, ProbeFileError, _json_object, _validate_delimiter
11
+ from .formats import FORMATS
12
+ from .probes import EXTENDED, select_probes
13
+
14
+ MAX_CONFIG_BYTES = 64 * 1024
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Config:
19
+ """Reusable options; explicit CLI flags take precedence over loaded values."""
20
+
21
+ file_format: str = "auto"
22
+ encoding: str | None = None
23
+ delimiter: str = ","
24
+ id_column: str = ID_COLUMN
25
+ value_column: str = VALUE_COLUMN
26
+ probe_set: str = "default"
27
+ custom_values: Path | None = None
28
+ strict_order: bool = False
29
+ schema_version: str = "1.0"
30
+
31
+ def __post_init__(self):
32
+ for name in ("file_format", "delimiter", "id_column", "value_column", "probe_set"):
33
+ value = getattr(self, name)
34
+ if not isinstance(value, str) or not value or len(value) > 255:
35
+ raise ProbeFileError(
36
+ f"Configuration {name} must be non-empty text (up to 255 chars)"
37
+ )
38
+ try:
39
+ value.encode("utf-8")
40
+ except UnicodeError as exc:
41
+ raise ProbeFileError(f"Configuration {name} must contain valid Unicode") from exc
42
+ if self.schema_version != "1.0":
43
+ raise ProbeFileError("Unsupported configuration schema_version")
44
+ if self.file_format not in ("auto", *FORMATS):
45
+ raise ProbeFileError("Unknown configuration file_format")
46
+ _validate_delimiter(self.delimiter)
47
+ if self.id_column == self.value_column:
48
+ raise ProbeFileError("ID and value columns must be different")
49
+ if type(self.strict_order) is not bool:
50
+ raise ProbeFileError("Configuration strict_order must be a boolean")
51
+ if self.encoding is not None:
52
+ if not isinstance(self.encoding, str) or not self.encoding or len(self.encoding) > 64:
53
+ raise ProbeFileError("Configuration encoding must be text or null")
54
+ try:
55
+ codecs.lookup(self.encoding)
56
+ except (LookupError, ValueError) as exc:
57
+ raise ProbeFileError("Unknown configuration encoding") from exc
58
+ if self.probe_set not in ("default", "extended", "none") and not set(
59
+ self.probe_set.split(",")
60
+ ) <= {p.category for p in EXTENDED}:
61
+ raise ProbeFileError("Unknown configuration probe_set or category")
62
+ if self.custom_values is not None:
63
+ if not isinstance(self.custom_values, (str, Path)) or not str(self.custom_values):
64
+ raise ProbeFileError("Configuration custom_values must be a path or null")
65
+ if "\x00" in str(self.custom_values):
66
+ raise ProbeFileError("Configuration custom_values cannot contain NUL")
67
+ object.__setattr__(self, "custom_values", Path(self.custom_values))
68
+
69
+ def make_options(self) -> dict:
70
+ """Keyword arguments for write_probes; generation uses fixed control columns."""
71
+ return dict(
72
+ file_format=self.file_format,
73
+ encoding=self.encoding or "utf-8",
74
+ delimiter=self.delimiter,
75
+ probes=select_probes(self.probe_set, self.custom_values),
76
+ )
77
+
78
+ def check_options(self) -> dict:
79
+ """Keyword arguments for check_file, with BOM-aware decoding by default."""
80
+ options = self.make_options()
81
+ options.update(
82
+ encoding=self.encoding or "utf-8-sig",
83
+ id_column=self.id_column,
84
+ value_column=self.value_column,
85
+ strict_order=self.strict_order,
86
+ )
87
+ return options
88
+
89
+
90
+ def load_config(path: Path) -> Config:
91
+ """Read at most 64 KiB of UTF-8 JSON; resolve custom paths beside the config."""
92
+ path = Path(path)
93
+ try:
94
+ with path.open("rb") as stream:
95
+ data = stream.read(MAX_CONFIG_BYTES + 1)
96
+ if len(data) > MAX_CONFIG_BYTES:
97
+ raise ProbeFileError("Configuration exceeds the 64 KiB safety limit")
98
+ document = json.loads(data.decode("utf-8-sig"), object_pairs_hook=_json_object)
99
+ except (ValueError, UnicodeError, RecursionError) as exc:
100
+ raise ProbeFileError(f"Invalid configuration JSON: {exc}") from exc
101
+ if not isinstance(document, dict) or document.keys() - {f.name for f in fields(Config)}:
102
+ raise ProbeFileError("Configuration must be an object with known option names")
103
+ config = Config(**document)
104
+ if config.custom_values is not None:
105
+ from dataclasses import replace
106
+
107
+ config = replace(
108
+ config, custom_values=(path.resolve().parent / config.custom_values).resolve()
109
+ )
110
+ return config
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "RowCanary configuration 1.0",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "schema_version": {"const": "1.0"},
8
+ "file_format": {"enum": ["auto", "csv", "tsv", "json", "jsonl", "xlsx"]},
9
+ "encoding": {"type": ["string", "null"], "minLength": 1, "maxLength": 64},
10
+ "delimiter": {"type": "string", "minLength": 1, "maxLength": 1, "pattern": "^[^\\r\\n\\u0000\"]$"},
11
+ "id_column": {"type": "string", "minLength": 1, "maxLength": 255},
12
+ "value_column": {"type": "string", "minLength": 1, "maxLength": 255},
13
+ "probe_set": {"type": "string", "minLength": 1, "maxLength": 255},
14
+ "custom_values": {"type": ["string", "null"], "minLength": 1, "pattern": "^[^\\u0000]+$"},
15
+ "strict_order": {"type": "boolean"}
16
+ }
17
+ }