parity-diff 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.
parity/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """parity - prove two tables in two different database engines hold the same data.
2
+
3
+ The public surface is deliberately small:
4
+
5
+ from parity import get_dialect, diff
6
+
7
+ Everything else is an implementation detail. Note that importing this package
8
+ pulls in no database driver; drivers are loaded lazily by ``get_dialect`` so a
9
+ DuckDB-only user is never forced to install a PostgreSQL driver.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = ["__version__", "diff", "get_dialect"]
19
+
20
+
21
+ def __getattr__(name: str) -> Any:
22
+ # Lazy re-export: keeps `import parity` free of driver imports.
23
+ if name == "get_dialect":
24
+ from parity.dialects.base import get_dialect
25
+
26
+ return get_dialect
27
+ if name == "diff":
28
+ from parity.engine import diff
29
+
30
+ return diff
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
parity/cli.py ADDED
@@ -0,0 +1,361 @@
1
+ """Command line entry point.
2
+
3
+ The exit codes are the product here: they are what let `parity diff` sit in a
4
+ CI pipeline as the gate on a migration cutover. Everything else is reporting.
5
+
6
+ Output leads with the verdict, then the evidence. The rows-downloaded
7
+ percentage is always printed, because it is the proof that the tool pushed the
8
+ work into the engines rather than dragging both tables across the network - and
9
+ because a number that suddenly reads 100% is how a user finds out something is
10
+ wrong with their key column.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from collections.abc import Sequence
19
+ from typing import Any, TextIO
20
+
21
+ from parity import __version__
22
+ from parity.dialects.base import NULL_SENTINEL
23
+ from parity.engine import DEFAULT_MAX_DIFFS
24
+ from parity.types import DiffResult, RowDiff
25
+
26
+ #: Exit codes. `1` means "differences found", not "crashed" - a CI job can tell
27
+ #: an honest disagreement from a broken connection string.
28
+ EXIT_IDENTICAL = 0
29
+ EXIT_DIFFERENCES = 1
30
+ EXIT_ERROR = 2
31
+
32
+ KIND_LABELS = {
33
+ "only_in_a": "only in A",
34
+ "only_in_b": "only in B",
35
+ "different": "different",
36
+ }
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(
41
+ prog="parity",
42
+ description=(
43
+ "Prove two tables in two different database engines hold the same "
44
+ "data - without moving the data out of either engine."
45
+ ),
46
+ epilog=(
47
+ "exit codes: 0 identical, 1 differences found, 2 error. "
48
+ "Connection strings look like postgres://user:pw@host/db or "
49
+ "duckdb:///path/to.duckdb"
50
+ ),
51
+ )
52
+ parser.add_argument("--version", action="version", version=f"parity {__version__}")
53
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
54
+
55
+ d = sub.add_parser(
56
+ "diff",
57
+ help="compare two tables across two engines",
58
+ description="Compare two tables and report exactly which rows differ.",
59
+ )
60
+ d.add_argument("--a", required=True, metavar="CONN", help="side A connection string")
61
+ d.add_argument("--a-table", required=True, metavar="TABLE", help="side A table")
62
+ d.add_argument("--b", required=True, metavar="CONN", help="side B connection string")
63
+ d.add_argument("--b-table", required=True, metavar="TABLE", help="side B table")
64
+ d.add_argument("--key", required=True, metavar="COL", help="integer key column")
65
+ d.add_argument(
66
+ "--columns", metavar="a,b,c",
67
+ help="compare only these columns (default: every column both sides share)",
68
+ )
69
+ d.add_argument("--exclude", metavar="x,y", help="skip these columns")
70
+ d.add_argument(
71
+ "--bisection-factor", type=int, default=32, metavar="N",
72
+ help="key-range buckets per level (default: 32)",
73
+ )
74
+ d.add_argument(
75
+ "--threshold", type=int, default=10_000, metavar="N",
76
+ help="stop bisecting and download once a differing range holds at most "
77
+ "N rows (default: 10000)",
78
+ )
79
+ d.add_argument(
80
+ "--float-scale", type=int, default=6, metavar="N",
81
+ help="decimal places at which floats and decimals are compared "
82
+ "(default: 6). Both sides always use the same value.",
83
+ )
84
+ d.add_argument(
85
+ "--max-diffs", type=int, default=DEFAULT_MAX_DIFFS, metavar="N",
86
+ help=f"stop after N differences (default: {DEFAULT_MAX_DIFFS:,}; 0 for "
87
+ f"no limit). The result is then explicitly marked partial - it "
88
+ f"does not mean the rest matched. The default exists because each "
89
+ f"difference costs memory, so two tables that share nothing would "
90
+ f"otherwise exhaust it rather than answering.",
91
+ )
92
+ d.add_argument("--json", action="store_true", help="machine-readable output")
93
+ d.add_argument(
94
+ "--quiet", action="store_true",
95
+ help="print nothing; rely on the exit code",
96
+ )
97
+ return parser
98
+
99
+
100
+ def _split(value: str | None) -> list[str]:
101
+ if not value:
102
+ return []
103
+ return [part.strip() for part in value.split(",") if part.strip()]
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Reporting
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def _plural(n: int, word: str) -> str:
112
+ return f"{n:,} {word}{'' if n == 1 else 's'}"
113
+
114
+
115
+ #: Cap on rows printed in human output. Without it a badly mismatched pair of
116
+ #: tables buries the terminal in a million lines. This is a *display* limit and
117
+ #: is reported as such - it is not `--max-diffs`, which stops the walk and makes
118
+ #: the result genuinely partial. Conflating the two would be exactly the kind of
119
+ #: "looks complete but isn't" this tool exists to avoid.
120
+ DISPLAY_LIMIT = 100
121
+
122
+ #: Longer than this and A/B go on separate lines instead of side by side.
123
+ _SIDE_BY_SIDE_MAX = 44
124
+
125
+
126
+ def _symbols(out: TextIO) -> dict[str, str]:
127
+ """Prefer the nicer glyphs, but never crash on a cp1252 console.
128
+
129
+ Windows terminals and redirected output still default to a legacy codepage
130
+ in plenty of setups, and a UnicodeEncodeError instead of a diff report is a
131
+ miserable first impression.
132
+ """
133
+ encoding = getattr(out, "encoding", None) or "ascii"
134
+ try:
135
+ "✗·✓".encode(encoding)
136
+ except (LookupError, UnicodeEncodeError):
137
+ return {"bad": "x", "ok": "=", "dot": "-", "partial": "?"}
138
+ return {"bad": "✗", "ok": "✓", "dot": "·", "partial": "!"}
139
+
140
+
141
+ def _display(value: str) -> str:
142
+ """Make canonical text readable without misrepresenting it.
143
+
144
+ `\\N` is the NULL sentinel and `''` is a genuinely empty string - the
145
+ distinction is the whole point of the NULL-versus-empty-string trap, and
146
+ printing the empty one as nothing at all makes the report look broken.
147
+ JSON output keeps the raw canonical text for machines.
148
+ """
149
+ if value == NULL_SENTINEL:
150
+ return "NULL"
151
+ if value == "":
152
+ return "''"
153
+ return value
154
+
155
+
156
+ def render_human(result: DiffResult, out: TextIO) -> None:
157
+ sym = _symbols(out)
158
+ stats = result.stats
159
+ total = max(stats.rows_compared_a, stats.rows_compared_b)
160
+ # Both sides' rows count toward `rows_downloaded`, so the denominator must
161
+ # be both sides too. Against a single side the figure can read 200%, which
162
+ # undermines the one number that proves the tool did the clever thing.
163
+ moveable = stats.rows_compared_a + stats.rows_compared_b
164
+ pct = (100 * stats.rows_downloaded / moveable) if moveable else 0.0
165
+
166
+ if result.truncated:
167
+ # Never let a capped run read as a verdict on the whole table.
168
+ headline = (
169
+ f"{sym['partial']} at least {_plural(len(result.diffs), 'difference')} "
170
+ f"in {total:,} rows - stopped early, the rest was not checked"
171
+ )
172
+ elif result.diffs:
173
+ headline = (
174
+ f"{sym['bad']} {_plural(len(result.diffs), 'difference')} "
175
+ f"in {total:,} rows"
176
+ )
177
+ else:
178
+ headline = f"{sym['ok']} no differences in {total:,} rows"
179
+ print(headline, file=out)
180
+
181
+ dot = f" {sym['dot']} "
182
+ queries = f"{stats.queries:,} quer{'y' if stats.queries == 1 else 'ies'}"
183
+ print(
184
+ f" {queries}{dot}{stats.rows_downloaded:,} rows downloaded "
185
+ f"({pct:.2f}% of both tables){dot}{stats.seconds:.1f}s",
186
+ file=out,
187
+ )
188
+
189
+ if result.diffs:
190
+ counts = [
191
+ f"{len(result.by_kind(k)):,} {KIND_LABELS[k]}"
192
+ for k in ("only_in_a", "only_in_b", "different")
193
+ if result.by_kind(k)
194
+ ]
195
+ if len(counts) > 1:
196
+ print(f" {dot.join(counts).strip()}", file=out)
197
+
198
+ print(file=out)
199
+ shown = 0
200
+ for kind in ("only_in_a", "only_in_b", "different"):
201
+ for d in result.by_kind(kind):
202
+ if shown >= DISPLAY_LIMIT:
203
+ break
204
+ _render_diff(d, out)
205
+ shown += 1
206
+ if len(result.diffs) > DISPLAY_LIMIT:
207
+ print(
208
+ f" ... {len(result.diffs) - DISPLAY_LIMIT:,} more difference(s) "
209
+ f"found but not shown here; use --json for the full list",
210
+ file=out,
211
+ )
212
+
213
+ print(file=out)
214
+ print(
215
+ f" comparing floats and decimals at {result.float_scale} decimal places",
216
+ file=out,
217
+ )
218
+ for warning in result.warnings:
219
+ print(f" ! {warning}", file=out)
220
+
221
+
222
+ def _render_diff(d: RowDiff, out: TextIO) -> None:
223
+ label = KIND_LABELS[d.kind]
224
+ if d.kind != "different":
225
+ print(f" {label:<11} key {d.key}", file=out)
226
+ return
227
+
228
+ print(f" {label:<11} key {d.key:<14} columns: {', '.join(d.columns)}", file=out)
229
+ name_w = max((len(c) for c in d.columns), default=0)
230
+ a_vals = {c: _display(d.values_a.get(c, "")) for c in d.columns}
231
+ b_vals = {c: _display(d.values_b.get(c, "")) for c in d.columns}
232
+
233
+ # Side by side is far easier to scan for the character that moved, but only
234
+ # while the values are short enough to stay on one line.
235
+ a_w = max((len(v) for v in a_vals.values()), default=0)
236
+ if a_w <= _SIDE_BY_SIDE_MAX:
237
+ for col in d.columns:
238
+ print(
239
+ f" {col:<{name_w}} A {a_vals[col]:<{a_w}} B {b_vals[col]}",
240
+ file=out,
241
+ )
242
+ else:
243
+ for col in d.columns:
244
+ print(f" {col:<{name_w}} A {a_vals[col]}", file=out)
245
+ print(f" {'':<{name_w}} B {b_vals[col]}", file=out)
246
+
247
+
248
+ def to_dict(result: DiffResult) -> dict[str, Any]:
249
+ stats = result.stats
250
+ moveable = stats.rows_compared_a + stats.rows_compared_b
251
+ return {
252
+ # `identical` is false whenever the walk was cut short, so a consumer
253
+ # that reads only this field can never be misled by a partial run.
254
+ "identical": result.identical,
255
+ "truncated": result.truncated,
256
+ "difference_count": len(result.diffs),
257
+ "float_scale": result.float_scale,
258
+ "columns_compared": [c.name for c in result.columns],
259
+ "warnings": result.warnings,
260
+ "stats": {
261
+ "queries": stats.queries,
262
+ "segments_checked": stats.segments_checked,
263
+ "rows_downloaded": stats.rows_downloaded,
264
+ "rows_a": stats.rows_compared_a,
265
+ "rows_b": stats.rows_compared_b,
266
+ # Denominator is both sides, matching `rows_downloaded`, so this
267
+ # can never exceed 100.
268
+ "percent_downloaded": round(
269
+ (100 * stats.rows_downloaded / moveable) if moveable else 0.0, 4
270
+ ),
271
+ "seconds": round(stats.seconds, 3),
272
+ },
273
+ "differences": [
274
+ {
275
+ "key": d.key,
276
+ "kind": d.kind,
277
+ "columns": d.columns,
278
+ "a": d.values_a,
279
+ "b": d.values_b,
280
+ }
281
+ for d in result.diffs
282
+ ],
283
+ }
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Entry point
288
+ # ---------------------------------------------------------------------------
289
+
290
+
291
+ def _run_diff(args: argparse.Namespace, out: TextIO) -> int:
292
+ # Imported here, not at module scope, so `parity --help` works with no
293
+ # database driver installed at all.
294
+ from parity.dialects.base import get_dialect
295
+ from parity.engine import diff
296
+
297
+ a = b = None
298
+ try:
299
+ # Each side is opened separately so a failure can name which one.
300
+ a = get_dialect(args.a, side="A", float_scale=args.float_scale)
301
+ b = get_dialect(args.b, side="B", float_scale=args.float_scale)
302
+ result = diff(
303
+ a, b,
304
+ a_table=args.a_table,
305
+ b_table=args.b_table,
306
+ key=args.key,
307
+ columns=_split(args.columns) or None,
308
+ exclude=_split(args.exclude),
309
+ bisection_factor=args.bisection_factor,
310
+ threshold=args.threshold,
311
+ # 0 is the documented way to ask for no limit at all.
312
+ max_diffs=args.max_diffs or None,
313
+ )
314
+ finally:
315
+ for side in (a, b):
316
+ if side is not None:
317
+ try:
318
+ side.close()
319
+ except Exception: # noqa: BLE001, S110
320
+ # Closing is best effort: a failure here must not mask
321
+ # whatever the diff itself raised.
322
+ pass
323
+
324
+ if not args.quiet:
325
+ if args.json:
326
+ json.dump(to_dict(result), out, indent=2)
327
+ print(file=out)
328
+ else:
329
+ render_human(result, out)
330
+
331
+ return EXIT_IDENTICAL if result.identical else EXIT_DIFFERENCES
332
+
333
+
334
+ def main(
335
+ argv: Sequence[str] | None = None,
336
+ out: TextIO | None = None,
337
+ err: TextIO | None = None,
338
+ ) -> int:
339
+ out = out if out is not None else sys.stdout
340
+ err = err if err is not None else sys.stderr
341
+
342
+ parser = build_parser()
343
+ args = parser.parse_args(argv)
344
+ if args.command is None:
345
+ parser.print_help(out)
346
+ return EXIT_ERROR
347
+
348
+ try:
349
+ return _run_diff(args, out)
350
+ except KeyboardInterrupt: # pragma: no cover
351
+ print("parity: interrupted", file=err)
352
+ return EXIT_ERROR
353
+ except Exception as exc: # noqa: BLE001 - the exit-2 contract, see below
354
+ # Anything that is not a clean verdict is exit 2, never exit 1: a CI
355
+ # job must be able to tell "the tables differ" from "the tool broke".
356
+ print(f"parity: {exc}", file=err)
357
+ return EXIT_ERROR
358
+
359
+
360
+ if __name__ == "__main__": # pragma: no cover
361
+ raise SystemExit(main())
File without changes