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 +25 -0
- rowcanary/__main__.py +4 -0
- rowcanary/cli.py +201 -0
- rowcanary/config.py +110 -0
- rowcanary/config.schema.json +17 -0
- rowcanary/core.py +428 -0
- rowcanary/diagnostics.py +119 -0
- rowcanary/formats.py +200 -0
- rowcanary/probes.py +98 -0
- rowcanary/report.schema.json +54 -0
- rowcanary/reporting.py +72 -0
- rowcanary/static/app.css +6 -0
- rowcanary/static/app.js +150 -0
- rowcanary/static/icon.svg +1 -0
- rowcanary/static/index.html +42 -0
- rowcanary/streaming.py +108 -0
- rowcanary/web.py +236 -0
- rowcanary-1.0.0.dist-info/METADATA +283 -0
- rowcanary-1.0.0.dist-info/RECORD +23 -0
- rowcanary-1.0.0.dist-info/WHEEL +5 -0
- rowcanary-1.0.0.dist-info/entry_points.txt +2 -0
- rowcanary-1.0.0.dist-info/licenses/LICENSE +21 -0
- rowcanary-1.0.0.dist-info/top_level.txt +1 -0
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
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
|
+
}
|