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 +31 -0
- parity/cli.py +361 -0
- parity/dialects/__init__.py +0 -0
- parity/dialects/base.py +487 -0
- parity/dialects/duckdb_dialect.py +132 -0
- parity/dialects/postgres_dialect.py +139 -0
- parity/engine.py +449 -0
- parity/types.py +125 -0
- parity_diff-0.1.0.dist-info/METADATA +268 -0
- parity_diff-0.1.0.dist-info/RECORD +14 -0
- parity_diff-0.1.0.dist-info/WHEEL +5 -0
- parity_diff-0.1.0.dist-info/entry_points.txt +2 -0
- parity_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
- parity_diff-0.1.0.dist-info/top_level.txt +1 -0
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
|