parity-diff 0.2.0__tar.gz → 0.2.1__tar.gz
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_diff-0.2.0/src/parity_diff.egg-info → parity_diff-0.2.1}/PKG-INFO +3 -1
- {parity_diff-0.2.0 → parity_diff-0.2.1}/pyproject.toml +4 -1
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/__init__.py +1 -1
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/base.py +19 -4
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/engine.py +19 -3
- {parity_diff-0.2.0 → parity_diff-0.2.1/src/parity_diff.egg-info}/PKG-INFO +3 -1
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/SOURCES.txt +3 -1
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/requires.txt +3 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/fakes.py +7 -1
- parity_diff-0.2.1/tests/test_fuzz_encoding.py +347 -0
- parity_diff-0.2.1/tests/test_properties.py +257 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/CONTRIBUTING.md +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/LICENSE +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/MANIFEST.in +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/README.md +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/setup.cfg +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/cli.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/__init__.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/duckdb_dialect.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/mysql_dialect.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/postgres_dialect.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/types.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/dependency_links.txt +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/entry_points.txt +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/top_level.txt +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/conftest.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_cli.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_encoding.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_engine.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_integration.py +0 -0
- {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_mysql.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parity-diff
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Prove two tables in two different database engines hold the same data - without moving the data out of either engine.
|
|
5
5
|
Author: Alessio Sorio
|
|
6
6
|
License-Expression: MIT
|
|
@@ -34,6 +34,8 @@ Provides-Extra: all
|
|
|
34
34
|
Requires-Dist: duckdb>=1.0; extra == "all"
|
|
35
35
|
Requires-Dist: psycopg[binary]>=3.1; extra == "all"
|
|
36
36
|
Requires-Dist: mysql-connector-python>=8.0; extra == "all"
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: hypothesis>=6.0; extra == "test"
|
|
37
39
|
Dynamic: license-file
|
|
38
40
|
|
|
39
41
|
# parity
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# PyPI by an empty project. The import name, the CLI command and the repo are
|
|
4
4
|
# all still `parity`: `pip install parity-diff` gives you `parity ...`.
|
|
5
5
|
name = "parity-diff"
|
|
6
|
-
version = "0.2.
|
|
6
|
+
version = "0.2.1"
|
|
7
7
|
description = "Prove two tables in two different database engines hold the same data - without moving the data out of either engine."
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
license = "MIT"
|
|
@@ -38,6 +38,9 @@ duckdb = ["duckdb>=1.0"]
|
|
|
38
38
|
postgres = ["psycopg[binary]>=3.1"]
|
|
39
39
|
mysql = ["mysql-connector-python>=8.0"]
|
|
40
40
|
all = ["duckdb>=1.0", "psycopg[binary]>=3.1", "mysql-connector-python>=8.0"]
|
|
41
|
+
# Test-only dependencies, kept out of every runtime extra. Hypothesis drives
|
|
42
|
+
# the generative suite (tests/test_properties.py); the core stays stdlib-only.
|
|
43
|
+
test = ["hypothesis>=6.0"]
|
|
41
44
|
|
|
42
45
|
[project.urls]
|
|
43
46
|
Homepage = "https://github.com/Aleixiou/parity-diff"
|
|
@@ -275,9 +275,10 @@ class Dialect(ABC):
|
|
|
275
275
|
# Two tables can legitimately share only their key - after
|
|
276
276
|
# `--columns`/`--exclude`, or when the schemas have diverged
|
|
277
277
|
# entirely. `concat_ws(chr(31), )` is a syntax error, so render a
|
|
278
|
-
# constant instead.
|
|
279
|
-
# `
|
|
280
|
-
#
|
|
278
|
+
# constant instead. The checksum query never passes an empty column
|
|
279
|
+
# set here: `segment_checksums` always folds the key in, so in that
|
|
280
|
+
# mode the key itself is what gets hashed. This branch stays as a
|
|
281
|
+
# defensive fallback for any other caller.
|
|
281
282
|
return "''"
|
|
282
283
|
return self._concat([self.normalize(c) for c in columns])
|
|
283
284
|
|
|
@@ -459,9 +460,23 @@ class Dialect(ABC):
|
|
|
459
460
|
# the one function whose off-by-one would make the walker skip rows.
|
|
460
461
|
offset = f"({self.wide_int(k)} - ({lo}))"
|
|
461
462
|
bucket = self.int_div(f"{offset} * {n_segments}", f"({hi - lo})")
|
|
463
|
+
# Fold the *key* into every row's hash, not just the comparable columns.
|
|
464
|
+
# A count plus a content-only sum cannot see a same-content insert and
|
|
465
|
+
# delete in one bucket: the counts balance (one in, one out) and equal
|
|
466
|
+
# content sums to the same value, so the bucket reads clean - a false
|
|
467
|
+
# "identical", on data as ordinary as two rows sharing a status or an
|
|
468
|
+
# empty string. Including the key makes an inserted key and a deleted
|
|
469
|
+
# key hash to different values, so the bucket sum changes and the walker
|
|
470
|
+
# recurses in. This only ever *adds* sensitivity: a checksum that
|
|
471
|
+
# differs is always re-checked by downloading and comparing the real
|
|
472
|
+
# rows, so an incidental mismatch costs a query, never a wrong verdict.
|
|
473
|
+
# It also subsumes the no-comparable-columns mode, where the key becomes
|
|
474
|
+
# the only thing hashed. `columns` never contains the key, so the key is
|
|
475
|
+
# hashed exactly once.
|
|
476
|
+
content = self.row_hash([*key.columns, *columns])
|
|
462
477
|
return (
|
|
463
478
|
f"select {bucket} as seg, count(*), "
|
|
464
|
-
f"{self.sum_wide(
|
|
479
|
+
f"{self.sum_wide(content)} "
|
|
465
480
|
f"from {self.qualify(table)} "
|
|
466
481
|
f"where {k} >= {lo} and {k} <= {hi - 1} "
|
|
467
482
|
f"group by 1"
|
|
@@ -411,10 +411,26 @@ def diff(
|
|
|
411
411
|
stats.segments_checked += 1
|
|
412
412
|
|
|
413
413
|
if span <= 1:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
414
|
+
# Only the *initial* range is ever this small: queued
|
|
415
|
+
# sub-ranges always have span > 1 (a single-key bucket is
|
|
416
|
+
# downloaded straight from the loop below, never re-queued).
|
|
417
|
+
# So this is a one-key table, and it must still be checksum-
|
|
418
|
+
# qualified rather than downloaded outright, or an identical
|
|
419
|
+
# one-row table moves rows and breaks the zero-download
|
|
420
|
+
# promise every other identical table keeps.
|
|
421
|
+
fa = pool.submit(
|
|
422
|
+
a.segment_checksums, a_table, key_a, a_cols, s_lo, s_hi, 1
|
|
417
423
|
)
|
|
424
|
+
fb = pool.submit(
|
|
425
|
+
b.segment_checksums, b_table, key_b, b_cols, s_lo, s_hi, 1
|
|
426
|
+
)
|
|
427
|
+
cs_a, cs_b = _gather(fa, fb)
|
|
428
|
+
stats.queries += 2
|
|
429
|
+
if cs_a.get(0, EMPTY) != cs_b.get(0, EMPTY):
|
|
430
|
+
_compare_rows(
|
|
431
|
+
pool, a, b, a_table, b_table, key_a, key_b,
|
|
432
|
+
a_cols, b_cols, s_lo, s_hi, diffs, stats,
|
|
433
|
+
)
|
|
418
434
|
continue
|
|
419
435
|
|
|
420
436
|
n = min(bisection_factor, span)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parity-diff
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Prove two tables in two different database engines hold the same data - without moving the data out of either engine.
|
|
5
5
|
Author: Alessio Sorio
|
|
6
6
|
License-Expression: MIT
|
|
@@ -34,6 +34,8 @@ Provides-Extra: all
|
|
|
34
34
|
Requires-Dist: duckdb>=1.0; extra == "all"
|
|
35
35
|
Requires-Dist: psycopg[binary]>=3.1; extra == "all"
|
|
36
36
|
Requires-Dist: mysql-connector-python>=8.0; extra == "all"
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: hypothesis>=6.0; extra == "test"
|
|
37
39
|
Dynamic: license-file
|
|
38
40
|
|
|
39
41
|
# parity
|
|
@@ -255,7 +255,13 @@ class FakeDialect(Dialect):
|
|
|
255
255
|
bucket = ((k - lo) * n_segments) // span
|
|
256
256
|
acc = out.setdefault(bucket, [0, 0])
|
|
257
257
|
acc[0] += 1
|
|
258
|
-
|
|
258
|
+
# Mirror Dialect._segment_sql: fold the key into the hash so a
|
|
259
|
+
# same-content insert and delete in one bucket cannot cancel. The
|
|
260
|
+
# integer key's canonical text is str(k), and row_text prepends it
|
|
261
|
+
# to the column text with the field separator.
|
|
262
|
+
col_text = self.table.text(k, columns)
|
|
263
|
+
combined = str(k) if not columns else f"{k}\x1f{col_text}"
|
|
264
|
+
acc[1] += row_hash(combined)
|
|
259
265
|
return {i: (c, s) for i, (c, s) in out.items()}
|
|
260
266
|
|
|
261
267
|
def fetch_range(
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Differential fuzzing of the cross-engine encoding contract.
|
|
2
|
+
|
|
3
|
+
`test_encoding.py` pins the encoding with a fixed table of hand-chosen cases -
|
|
4
|
+
the seventeen values CLAUDE.md section 4 was verified against. This file attacks
|
|
5
|
+
the same contract from the other side: it *generates* hundreds of values, many
|
|
6
|
+
of them deliberately hostile (the field separator byte itself, the NULL
|
|
7
|
+
sentinel spelled as real data, emoji, combining marks, right-to-left text,
|
|
8
|
+
bigint extremes, microsecond timestamps), inserts the **same Python value** into
|
|
9
|
+
both engines, and asserts they render byte-identical canonical text and the same
|
|
10
|
+
60-bit row hash.
|
|
11
|
+
|
|
12
|
+
Because the value inserted into each side is identical, any disagreement is a
|
|
13
|
+
pure *encoding* difference - exactly the class of bug that makes a migration
|
|
14
|
+
look clean when a NULL quietly became an empty string, or a timestamp lost its
|
|
15
|
+
microseconds on one engine only. A fixed case list can only catch the
|
|
16
|
+
differences someone already thought to write down; this catches the ones nobody
|
|
17
|
+
did.
|
|
18
|
+
|
|
19
|
+
Values are inserted through the drivers' parameter binding, never as SQL
|
|
20
|
+
literals, so an arbitrary string cannot escape into the statement - the fuzz
|
|
21
|
+
input is data, not code. The run is seeded, so a failure reproduces exactly.
|
|
22
|
+
|
|
23
|
+
Skips, never fails, when an engine is unreachable (see conftest).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import datetime as dt
|
|
29
|
+
import random
|
|
30
|
+
from decimal import Decimal
|
|
31
|
+
|
|
32
|
+
import pytest
|
|
33
|
+
from conftest import open_duckdb, open_pg
|
|
34
|
+
|
|
35
|
+
from parity.dialects.base import Dialect
|
|
36
|
+
from parity.engine import diff
|
|
37
|
+
from parity.types import Column
|
|
38
|
+
|
|
39
|
+
# A schema and a DuckDB file of this module's own, so nothing here collides with
|
|
40
|
+
# the fixtures in test_encoding.py (DuckDB permits a single writer per file).
|
|
41
|
+
FUZZ_SCHEMA = "parity_fuzz"
|
|
42
|
+
FUZZ_TABLE = "fuzz_values"
|
|
43
|
+
SEED = 20240906
|
|
44
|
+
N_RANDOM = 250
|
|
45
|
+
|
|
46
|
+
# id bigint, then one column per fuzzable logical type. The tuple is
|
|
47
|
+
# (name, postgres type, duckdb type).
|
|
48
|
+
COLUMNS: list[tuple[str, str, str]] = [
|
|
49
|
+
("id", "bigint", "bigint"),
|
|
50
|
+
("s", "text", "varchar"),
|
|
51
|
+
("i", "bigint", "bigint"),
|
|
52
|
+
("d", "decimal(20,6)", "decimal(20,6)"),
|
|
53
|
+
("b", "boolean", "boolean"),
|
|
54
|
+
("dd", "date", "date"),
|
|
55
|
+
("ts", "timestamp", "timestamp"),
|
|
56
|
+
]
|
|
57
|
+
VALUE_COLUMNS = [name for name, *_ in COLUMNS if name != "id"]
|
|
58
|
+
|
|
59
|
+
# Characters excluded from generated strings for reasons unrelated to parity:
|
|
60
|
+
# PostgreSQL's `text` type cannot store a NUL byte at all, and lone surrogates
|
|
61
|
+
# have no UTF-8 encoding. Everything else - including the 0x1f field separator
|
|
62
|
+
# and the two bytes of the `\N` sentinel - is fair game: a single column's
|
|
63
|
+
# canonical text never involves the separator, so both engines must still
|
|
64
|
+
# render these identically, and if they don't that is the bug.
|
|
65
|
+
_FORBIDDEN = {"\x00"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _rand_string(rng: random.Random) -> str:
|
|
69
|
+
"""A random string drawn from a deliberately hostile alphabet."""
|
|
70
|
+
palette = (
|
|
71
|
+
"abcABC012 "
|
|
72
|
+
"héllo wörld" # accented latin
|
|
73
|
+
"日本語한국어" # CJK
|
|
74
|
+
"\U0001f389\U0001f600" # emoji (astral plane)
|
|
75
|
+
"éñ" # combining marks
|
|
76
|
+
"" # RTL / override controls
|
|
77
|
+
"\x1f" # the field separator, as real data
|
|
78
|
+
"\t\r\n" # whitespace controls
|
|
79
|
+
)
|
|
80
|
+
length = rng.randint(0, 24)
|
|
81
|
+
chars = [rng.choice(palette) for _ in range(length)]
|
|
82
|
+
s = "".join(c for c in chars if c not in _FORBIDDEN)
|
|
83
|
+
# Encoding round-trips as UTF-8 downstream; drop anything that cannot.
|
|
84
|
+
return s.encode("utf-8", "ignore").decode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _rand_decimal(rng: random.Random) -> Decimal:
|
|
88
|
+
"""A decimal with at most 6 places, so no rounding happens at insert time.
|
|
89
|
+
|
|
90
|
+
A value with more than 6 places would be rounded into decimal(20,6) by the
|
|
91
|
+
INSERT, and if the two engines rounded it differently the *stored* values
|
|
92
|
+
would differ - a value difference masquerading as an encoding difference.
|
|
93
|
+
Six places keeps the inserted value exact on both sides.
|
|
94
|
+
"""
|
|
95
|
+
unscaled = rng.randint(-(10**12), 10**12)
|
|
96
|
+
scale = rng.randint(0, 6)
|
|
97
|
+
return (Decimal(unscaled) / (Decimal(10) ** scale)).quantize(Decimal("0.000001"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _rand_timestamp(rng: random.Random) -> dt.datetime:
|
|
101
|
+
"""A timestamp somewhere in a wide range, to microsecond precision."""
|
|
102
|
+
base = dt.datetime(1000, 1, 1)
|
|
103
|
+
return base + dt.timedelta(
|
|
104
|
+
days=rng.randint(0, 3_000_000),
|
|
105
|
+
seconds=rng.randint(0, 86_399),
|
|
106
|
+
microseconds=rng.randint(0, 999_999),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _adversarial_rows() -> list[tuple]:
|
|
111
|
+
"""Hand-picked hostile rows, prepended so they are always exercised.
|
|
112
|
+
|
|
113
|
+
Each is a `(s, i, d, b, dd, ts)` tuple; `None` means SQL NULL, which must
|
|
114
|
+
render as the sentinel rather than poison the row.
|
|
115
|
+
"""
|
|
116
|
+
return [
|
|
117
|
+
("", 0, Decimal("0.000000"), False, dt.date(1970, 1, 1), dt.datetime(2024, 1, 1)),
|
|
118
|
+
("\x1f", 1, Decimal("1.500000"), True, dt.date(2024, 2, 29),
|
|
119
|
+
dt.datetime(2024, 2, 29, 13, 4, 5, 123456)),
|
|
120
|
+
# The sentinel spelled as genuine string data - both engines must still
|
|
121
|
+
# agree on how they render it, whatever the diff logic later makes of it.
|
|
122
|
+
("\\N", -1, Decimal("-0.125000"), None, None, None),
|
|
123
|
+
("O'Brien \"x\"", 9223372036854775807, Decimal("123456789.987654"),
|
|
124
|
+
True, dt.date(9999, 12, 31), dt.datetime(9999, 12, 31, 23, 59, 59, 999999)),
|
|
125
|
+
(None, -9223372036854775808, None, False, dt.date(1, 1, 1),
|
|
126
|
+
dt.datetime(1, 1, 1, 0, 0, 0)),
|
|
127
|
+
("héllo 日本語 \U0001f389", None, Decimal("-99999999999999.999999"),
|
|
128
|
+
None, dt.date(2000, 1, 1), None),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _rows() -> list[tuple]:
|
|
133
|
+
"""The full fuzz corpus: the adversarial rows, then N random ones."""
|
|
134
|
+
rng = random.Random(SEED)
|
|
135
|
+
rows = list(_adversarial_rows())
|
|
136
|
+
for _ in range(N_RANDOM):
|
|
137
|
+
rows.append((
|
|
138
|
+
_rand_string(rng) if rng.random() > 0.1 else None,
|
|
139
|
+
rng.randint(-(2**63), 2**63 - 1) if rng.random() > 0.1 else None,
|
|
140
|
+
_rand_decimal(rng) if rng.random() > 0.1 else None,
|
|
141
|
+
rng.choice([True, False]) if rng.random() > 0.1 else None,
|
|
142
|
+
_rand_timestamp(rng).date() if rng.random() > 0.1 else None,
|
|
143
|
+
_rand_timestamp(rng) if rng.random() > 0.1 else None,
|
|
144
|
+
))
|
|
145
|
+
return rows
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
ROWS = _rows()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------------
|
|
152
|
+
# Fixtures: build an identical fuzz table in each engine.
|
|
153
|
+
# --------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.fixture(scope="module")
|
|
157
|
+
def duck_fuzz(tmp_path_factory: pytest.TempPathFactory) -> Dialect:
|
|
158
|
+
"""Load the fuzz corpus into a private DuckDB file, then read it back."""
|
|
159
|
+
import duckdb
|
|
160
|
+
from conftest import _duckdb_available
|
|
161
|
+
|
|
162
|
+
ok, why = _duckdb_available()
|
|
163
|
+
if not ok:
|
|
164
|
+
pytest.skip(why)
|
|
165
|
+
|
|
166
|
+
path = str(tmp_path_factory.mktemp("fuzz") / "fuzz.duckdb")
|
|
167
|
+
con = duckdb.connect(path)
|
|
168
|
+
try:
|
|
169
|
+
cols = ", ".join(f"{n} {t[1]}" for n, *t in COLUMNS)
|
|
170
|
+
con.execute(f"create table {FUZZ_TABLE} ({cols})")
|
|
171
|
+
placeholders = ", ".join(["?"] * len(COLUMNS))
|
|
172
|
+
con.executemany(
|
|
173
|
+
f"insert into {FUZZ_TABLE} values ({placeholders})",
|
|
174
|
+
[(i, *row) for i, row in enumerate(ROWS)],
|
|
175
|
+
)
|
|
176
|
+
finally:
|
|
177
|
+
con.close()
|
|
178
|
+
|
|
179
|
+
dialect = open_duckdb(path, side="B")
|
|
180
|
+
yield dialect
|
|
181
|
+
dialect.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.fixture(scope="module")
|
|
185
|
+
def pg_fuzz(pg_url: str) -> Dialect:
|
|
186
|
+
"""Load the fuzz corpus into a private PostgreSQL schema, then read it back."""
|
|
187
|
+
import psycopg
|
|
188
|
+
|
|
189
|
+
con = psycopg.connect(pg_url, autocommit=True)
|
|
190
|
+
try:
|
|
191
|
+
con.execute(f"drop schema if exists {FUZZ_SCHEMA} cascade")
|
|
192
|
+
con.execute(f"create schema {FUZZ_SCHEMA}")
|
|
193
|
+
cols = ", ".join(f"{n} {t[0]}" for n, *t in COLUMNS)
|
|
194
|
+
con.execute(f"create table {FUZZ_SCHEMA}.{FUZZ_TABLE} ({cols})")
|
|
195
|
+
placeholders = ", ".join(["%s"] * len(COLUMNS))
|
|
196
|
+
with con.cursor() as cur:
|
|
197
|
+
cur.executemany(
|
|
198
|
+
f"insert into {FUZZ_SCHEMA}.{FUZZ_TABLE} values ({placeholders})",
|
|
199
|
+
[(i, *row) for i, row in enumerate(ROWS)],
|
|
200
|
+
)
|
|
201
|
+
finally:
|
|
202
|
+
con.close()
|
|
203
|
+
|
|
204
|
+
dialect = open_pg(pg_url, side="A")
|
|
205
|
+
yield dialect
|
|
206
|
+
dialect.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _qualified(dialect: Dialect) -> str:
|
|
210
|
+
"""The fuzz table, qualified for whichever engine this is."""
|
|
211
|
+
schema = FUZZ_SCHEMA if dialect.name == "postgres" else "main"
|
|
212
|
+
return f"{schema}.{FUZZ_TABLE}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _column(dialect: Dialect, name: str) -> Column:
|
|
216
|
+
"""The introspected Column of the given name on this engine's fuzz table."""
|
|
217
|
+
for col in dialect.columns(_qualified(dialect)):
|
|
218
|
+
if col.name == name:
|
|
219
|
+
return col
|
|
220
|
+
raise AssertionError(f"no column {name!r} on side {dialect.side}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------
|
|
224
|
+
# The differential assertions.
|
|
225
|
+
# --------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@pytest.mark.postgres
|
|
229
|
+
@pytest.mark.parametrize("column", VALUE_COLUMNS)
|
|
230
|
+
def test_random_values_normalize_identically_across_engines(pg_fuzz, duck_fuzz, column):
|
|
231
|
+
"""Every fuzzed value renders to byte-identical canonical text on both engines.
|
|
232
|
+
|
|
233
|
+
One column at a time, all rows at once. A single disagreeing row - a lost
|
|
234
|
+
microsecond, a NULL that rendered as SQL NULL instead of the sentinel, a
|
|
235
|
+
Unicode byte one engine folded - fails the test and names the row.
|
|
236
|
+
"""
|
|
237
|
+
pg_col = _column(pg_fuzz, column)
|
|
238
|
+
duck_col = _column(duck_fuzz, column)
|
|
239
|
+
pg_rows = pg_fuzz.query(
|
|
240
|
+
f"select id, {pg_fuzz.normalize(pg_col)} "
|
|
241
|
+
f"from {pg_fuzz.qualify(_qualified(pg_fuzz))} order by id"
|
|
242
|
+
)
|
|
243
|
+
duck_rows = duck_fuzz.query(
|
|
244
|
+
f"select id, {duck_fuzz.normalize(duck_col)} "
|
|
245
|
+
f"from {duck_fuzz.qualify(_qualified(duck_fuzz))} order by id"
|
|
246
|
+
)
|
|
247
|
+
assert len(pg_rows) == len(duck_rows) == len(ROWS)
|
|
248
|
+
for (pid, pval), (did, dval) in zip(pg_rows, duck_rows, strict=True):
|
|
249
|
+
assert pid == did
|
|
250
|
+
assert pval == dval, (
|
|
251
|
+
f"column {column!r} row {pid}: postgres rendered {pval!r}, "
|
|
252
|
+
f"duckdb rendered {dval!r} for input {ROWS[pid][VALUE_COLUMNS.index(column)]!r}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.mark.postgres
|
|
257
|
+
def test_random_rows_hash_identically_across_engines(pg_fuzz, duck_fuzz):
|
|
258
|
+
"""The whole fuzzed row folds to the same 60-bit hash on both engines.
|
|
259
|
+
|
|
260
|
+
This is the row-level contract, not just per-column: the concatenation, the
|
|
261
|
+
separator, the coalesce, and the fold all have to agree at once, over every
|
|
262
|
+
generated row.
|
|
263
|
+
"""
|
|
264
|
+
pg_cols = sorted(
|
|
265
|
+
(c for c in pg_fuzz.columns(_qualified(pg_fuzz)) if c.name != "id"),
|
|
266
|
+
key=lambda c: c.name,
|
|
267
|
+
)
|
|
268
|
+
duck_cols = sorted(
|
|
269
|
+
(c for c in duck_fuzz.columns(_qualified(duck_fuzz)) if c.name != "id"),
|
|
270
|
+
key=lambda c: c.name,
|
|
271
|
+
)
|
|
272
|
+
pg_rows = pg_fuzz.query(
|
|
273
|
+
f"select id, {pg_fuzz.row_hash(pg_cols)} "
|
|
274
|
+
f"from {pg_fuzz.qualify(_qualified(pg_fuzz))} order by id"
|
|
275
|
+
)
|
|
276
|
+
duck_rows = duck_fuzz.query(
|
|
277
|
+
f"select id, {duck_fuzz.row_hash(duck_cols)} "
|
|
278
|
+
f"from {duck_fuzz.qualify(_qualified(duck_fuzz))} order by id"
|
|
279
|
+
)
|
|
280
|
+
assert len(pg_rows) == len(duck_rows) == len(ROWS)
|
|
281
|
+
for (pid, phash), (did, dhash) in zip(pg_rows, duck_rows, strict=True):
|
|
282
|
+
assert pid == did
|
|
283
|
+
assert int(phash) == int(dhash), (
|
|
284
|
+
f"row {pid} hashed to {phash} on postgres, {dhash} on duckdb: "
|
|
285
|
+
f"input {ROWS[pid]!r}"
|
|
286
|
+
)
|
|
287
|
+
assert 0 <= int(phash) < 2**60, f"row {pid} hash out of 60-bit range"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@pytest.mark.postgres
|
|
291
|
+
def test_the_whole_engine_calls_two_fuzzed_tables_identical(pg_fuzz, duck_fuzz):
|
|
292
|
+
"""End to end, over fuzzed data: the same rows in Postgres and DuckDB match.
|
|
293
|
+
|
|
294
|
+
Not just the encoding helpers - the real `diff()` walk, hashing pushed into
|
|
295
|
+
both engines and the key range bisected, over a table of hostile values.
|
|
296
|
+
Identical data must download zero rows and report identical; if any fuzzed
|
|
297
|
+
value hashed differently across engines, the walk would chase a phantom
|
|
298
|
+
difference and this would fail.
|
|
299
|
+
"""
|
|
300
|
+
result = diff(pg_fuzz, duck_fuzz, _qualified(pg_fuzz), _qualified(duck_fuzz), "id")
|
|
301
|
+
assert result.identical, f"fuzzed tables reported different: {result.diffs[:5]}"
|
|
302
|
+
assert result.stats.rows_downloaded == 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pytest.mark.postgres
|
|
306
|
+
def test_a_single_planted_change_in_fuzzed_data_is_found(pg_fuzz, tmp_path):
|
|
307
|
+
"""A parity tool that never plants a difference proves nothing (CLAUDE.md 8).
|
|
308
|
+
|
|
309
|
+
Build a fresh DuckDB copy of the fuzz corpus with exactly one row's string
|
|
310
|
+
column changed, diff the untouched Postgres table against it, and confirm
|
|
311
|
+
the real walk reports precisely that row and names the changed column - on
|
|
312
|
+
hostile fuzzed data, across two engines. Building a fresh copy (rather than
|
|
313
|
+
mutating a table a dialect already holds open) keeps the change visible: a
|
|
314
|
+
read-only dialect pins a snapshot at connect time and would not see a
|
|
315
|
+
mid-session write from another connection.
|
|
316
|
+
"""
|
|
317
|
+
import duckdb
|
|
318
|
+
|
|
319
|
+
victim = 3 # an adversarial row, guaranteed present
|
|
320
|
+
path = str(tmp_path / "perturbed.duckdb")
|
|
321
|
+
con = duckdb.connect(path)
|
|
322
|
+
try:
|
|
323
|
+
cols = ", ".join(f"{n} {t[1]}" for n, *t in COLUMNS)
|
|
324
|
+
con.execute(f"create table {FUZZ_TABLE} ({cols})")
|
|
325
|
+
placeholders = ", ".join(["?"] * len(COLUMNS))
|
|
326
|
+
rows = []
|
|
327
|
+
for i, row in enumerate(ROWS):
|
|
328
|
+
r = list(row)
|
|
329
|
+
if i == victim:
|
|
330
|
+
r[VALUE_COLUMNS.index("s")] = "PERTURBED"
|
|
331
|
+
rows.append((i, *r))
|
|
332
|
+
con.executemany(f"insert into {FUZZ_TABLE} values ({placeholders})", rows)
|
|
333
|
+
finally:
|
|
334
|
+
con.close()
|
|
335
|
+
|
|
336
|
+
perturbed = open_duckdb(path, side="B")
|
|
337
|
+
try:
|
|
338
|
+
result = diff(
|
|
339
|
+
pg_fuzz, perturbed, _qualified(pg_fuzz), f"main.{FUZZ_TABLE}", "id"
|
|
340
|
+
)
|
|
341
|
+
assert [(d.key, d.kind) for d in result.diffs] == [(victim, "different")], (
|
|
342
|
+
f"expected exactly row {victim} to differ, got "
|
|
343
|
+
f"{[(d.key, d.kind) for d in result.diffs]}"
|
|
344
|
+
)
|
|
345
|
+
assert "s" in result.diffs[0].columns
|
|
346
|
+
finally:
|
|
347
|
+
perturbed.close()
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Property-based, oracle, and metamorphic tests for the bisection engine.
|
|
2
|
+
|
|
3
|
+
Everything here is generative: Hypothesis builds thousands of tables and the
|
|
4
|
+
tests assert properties that must hold for *all* of them, rather than for a few
|
|
5
|
+
hand-picked cases. The centrepiece is the **oracle** - parity's segmented,
|
|
6
|
+
network-frugal diff is checked against a trivial "download everything and
|
|
7
|
+
compare in Python" reference over random inputs. Any logic error in the walk,
|
|
8
|
+
the bucket arithmetic, or the row comparison shows up as a disagreement with
|
|
9
|
+
the oracle, on an input no human thought to write down.
|
|
10
|
+
|
|
11
|
+
All of it runs against the in-memory `FakeDialect`, so it needs no database and
|
|
12
|
+
runs everywhere - and `FakeDialect.query` raises, so these also keep proving the
|
|
13
|
+
engine never reaches past the dialect contract to build SQL itself.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
# Hypothesis is a test-only dependency (the `test` extra). If it is somehow
|
|
21
|
+
# absent, skip this whole module rather than fail collection - a missing test
|
|
22
|
+
# dependency must not look like a broken build.
|
|
23
|
+
pytest.importorskip("hypothesis")
|
|
24
|
+
|
|
25
|
+
from fakes import DictTable, FakeDialect
|
|
26
|
+
from hypothesis import given, settings
|
|
27
|
+
from hypothesis import strategies as st
|
|
28
|
+
|
|
29
|
+
from parity.engine import bucket_bounds, diff
|
|
30
|
+
from parity.types import Column, LogicalType
|
|
31
|
+
|
|
32
|
+
COLS = [
|
|
33
|
+
Column("a", LogicalType.STRING, "varchar"),
|
|
34
|
+
Column("b", LogicalType.STRING, "varchar"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Text values that avoid the field separator (0x1f) and the NULL sentinel, so
|
|
38
|
+
# these tests isolate the *bisection* logic from the *encoding*. The separator
|
|
39
|
+
# and sentinel get their own adversarial tests in test_fuzz_encoding.py.
|
|
40
|
+
_safe_text = st.text(
|
|
41
|
+
alphabet=st.characters(blacklist_characters="\x1f", blacklist_categories=("Cs",)),
|
|
42
|
+
max_size=12,
|
|
43
|
+
)
|
|
44
|
+
_row = st.tuples(_safe_text, _safe_text)
|
|
45
|
+
# Keys span negatives and a wide range, so bucketing edge cases are exercised.
|
|
46
|
+
_key = st.integers(min_value=-(10**6), max_value=10**6)
|
|
47
|
+
_table = st.dictionaries(_key, _row, max_size=60)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _oracle(rows_a: dict, rows_b: dict) -> list[tuple[int, str]]:
|
|
51
|
+
"""The truth, computed the dumb way: compare every row directly.
|
|
52
|
+
|
|
53
|
+
parity must reproduce this exactly, however cleverly it gets there.
|
|
54
|
+
"""
|
|
55
|
+
out = []
|
|
56
|
+
for k in set(rows_a) | set(rows_b):
|
|
57
|
+
a, b = rows_a.get(k), rows_b.get(k)
|
|
58
|
+
if a is None:
|
|
59
|
+
out.append((k, "only_in_b"))
|
|
60
|
+
elif b is None:
|
|
61
|
+
out.append((k, "only_in_a"))
|
|
62
|
+
elif a != b:
|
|
63
|
+
out.append((k, "different"))
|
|
64
|
+
return sorted(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run(rows_a: dict, rows_b: dict, **kwargs):
|
|
68
|
+
"""Diff two literal row dicts through the engine, over the in-memory fake."""
|
|
69
|
+
a = FakeDialect(DictTable(COLS, rows_a), side="A")
|
|
70
|
+
b = FakeDialect(DictTable(COLS, rows_b), side="B")
|
|
71
|
+
return diff(a, b, "a.t", "b.t", "id", **kwargs)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _kinds(result) -> list[tuple[int, str]]:
|
|
75
|
+
"""The result as a sorted (key, kind) list, for comparison against the oracle."""
|
|
76
|
+
return sorted((d.key, d.kind) for d in result.diffs)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# The oracle: the segmented walk must equal a brute-force comparison, always.
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@settings(max_examples=400)
|
|
85
|
+
@given(a=_table, b=_table)
|
|
86
|
+
def test_diff_matches_a_brute_force_oracle(a, b):
|
|
87
|
+
"""For any two tables, parity's result equals comparing every row directly.
|
|
88
|
+
|
|
89
|
+
This is the whole engine under test at once. A skipped range, an
|
|
90
|
+
off-by-one bucket, a misclassified row - any of them makes this fail on
|
|
91
|
+
some generated input.
|
|
92
|
+
"""
|
|
93
|
+
assert _kinds(_run(a, b)) == _oracle(a, b)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@settings(max_examples=200)
|
|
97
|
+
@given(
|
|
98
|
+
a=_table,
|
|
99
|
+
b=_table,
|
|
100
|
+
bisection_factor=st.integers(min_value=2, max_value=64),
|
|
101
|
+
threshold=st.integers(min_value=1, max_value=50),
|
|
102
|
+
)
|
|
103
|
+
def test_result_is_invariant_to_the_tuning_knobs(a, b, bisection_factor, threshold):
|
|
104
|
+
"""bisection_factor and threshold change cost, never the verdict.
|
|
105
|
+
|
|
106
|
+
Fan-out and download-threshold are performance dials. If either changes
|
|
107
|
+
*which* rows are reported, the walk is wrong.
|
|
108
|
+
"""
|
|
109
|
+
assert _kinds(_run(a, b, bisection_factor=bisection_factor, threshold=threshold)) == _oracle(a, b)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Metamorphic properties: relations that must hold between related runs.
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@settings(max_examples=200)
|
|
118
|
+
@given(t=_table)
|
|
119
|
+
def test_a_table_diffed_against_itself_is_identical(t):
|
|
120
|
+
"""The most basic invariant, and a false match here is catastrophic."""
|
|
121
|
+
result = _run(t, t)
|
|
122
|
+
assert result.identical
|
|
123
|
+
assert result.diffs == []
|
|
124
|
+
assert result.stats.rows_downloaded == 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@settings(max_examples=300)
|
|
128
|
+
@given(a=_table, b=_table)
|
|
129
|
+
def test_diff_is_symmetric_under_side_swap(a, b):
|
|
130
|
+
"""diff(A,B) and diff(B,A) are mirror images: only_in_a <-> only_in_b,
|
|
131
|
+
different stays different, same keys throughout."""
|
|
132
|
+
forward = dict(_kinds(_run(a, b)))
|
|
133
|
+
backward = dict(_kinds(_run(b, a)))
|
|
134
|
+
flip = {"only_in_a": "only_in_b", "only_in_b": "only_in_a", "different": "different"}
|
|
135
|
+
assert backward == {k: flip[v] for k, v in forward.items()}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@settings(max_examples=200)
|
|
139
|
+
@given(t=_table, extra=_table)
|
|
140
|
+
def test_rows_downloaded_is_zero_exactly_when_identical(t, extra):
|
|
141
|
+
"""The core efficiency promise, stated as an iff over random inputs."""
|
|
142
|
+
same = _run(t, t)
|
|
143
|
+
assert same.stats.rows_downloaded == 0 and same.identical
|
|
144
|
+
|
|
145
|
+
other = _run(t, extra)
|
|
146
|
+
if _oracle(t, extra): # they genuinely differ
|
|
147
|
+
assert other.stats.rows_downloaded > 0 and not other.identical
|
|
148
|
+
else:
|
|
149
|
+
assert other.stats.rows_downloaded == 0 and other.identical
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@settings(max_examples=200)
|
|
153
|
+
@given(
|
|
154
|
+
base=st.dictionaries(_key, _row, min_size=1, max_size=40),
|
|
155
|
+
changed=st.integers(min_value=0, max_value=39),
|
|
156
|
+
)
|
|
157
|
+
def test_exactly_n_changed_rows_are_found(base, changed):
|
|
158
|
+
"""Change a chosen number of rows; the walk must find exactly that many."""
|
|
159
|
+
keys = sorted(base)
|
|
160
|
+
n = min(changed, len(keys))
|
|
161
|
+
b = dict(base)
|
|
162
|
+
for k in keys[:n]:
|
|
163
|
+
b[k] = (base[k][0] + "X", base[k][1]) # guaranteed different
|
|
164
|
+
result = _run(base, b)
|
|
165
|
+
assert [d.kind for d in result.diffs] == ["different"] * n
|
|
166
|
+
assert {d.key for d in result.diffs} == set(keys[:n])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@settings(max_examples=150)
|
|
170
|
+
@given(a=_table, b=_table)
|
|
171
|
+
def test_excluding_every_differing_column_yields_identical(a, b):
|
|
172
|
+
"""Metamorphic: a diff driven only by column values disappears when those
|
|
173
|
+
columns are excluded. Only key-presence differences can remain."""
|
|
174
|
+
result = _run(a, b, exclude=["a", "b"])
|
|
175
|
+
presence_only = sorted(
|
|
176
|
+
(k, kind) for k, kind in _oracle(a, b) if kind != "different"
|
|
177
|
+
)
|
|
178
|
+
assert _kinds(result) == presence_only
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# bucket_bounds: the inverse of the SQL bucket expression, over the whole space.
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _sql_bucket(key: int, lo: int, hi: int, n: int) -> int:
|
|
187
|
+
"""The SQL bucket expression in Python: which segment a key falls in."""
|
|
188
|
+
return ((key - lo) * n) // (hi - lo)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@settings(max_examples=500)
|
|
192
|
+
@given(
|
|
193
|
+
lo=st.integers(min_value=-(10**9), max_value=10**9),
|
|
194
|
+
span=st.integers(min_value=2, max_value=10**7),
|
|
195
|
+
n=st.integers(min_value=2, max_value=1000),
|
|
196
|
+
)
|
|
197
|
+
def test_bucket_bounds_tiles_the_range_with_no_gap_or_overlap(lo, span, n):
|
|
198
|
+
"""Every key lands in exactly one bucket, and the buckets cover [lo, hi).
|
|
199
|
+
|
|
200
|
+
A gap is a row the walker never looks at while still reporting a clean
|
|
201
|
+
match - the worst failure this tool can have. Tested over the full space
|
|
202
|
+
of (lo, span, n), not a handful of seeds.
|
|
203
|
+
"""
|
|
204
|
+
hi = lo + span
|
|
205
|
+
n = min(n, span)
|
|
206
|
+
prev_hi = lo
|
|
207
|
+
for i in range(n):
|
|
208
|
+
b_lo, b_hi = bucket_bounds(i, lo, hi, n)
|
|
209
|
+
assert b_lo == prev_hi, f"gap or overlap before bucket {i}"
|
|
210
|
+
assert b_lo <= b_hi
|
|
211
|
+
prev_hi = b_hi
|
|
212
|
+
assert prev_hi == hi, "buckets do not reach the end of the range"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@settings(max_examples=300)
|
|
216
|
+
@given(
|
|
217
|
+
lo=st.integers(min_value=-1000, max_value=1000),
|
|
218
|
+
span=st.integers(min_value=2, max_value=3000),
|
|
219
|
+
n=st.integers(min_value=2, max_value=200),
|
|
220
|
+
offset=st.integers(min_value=0),
|
|
221
|
+
)
|
|
222
|
+
def test_bucket_bounds_agrees_with_the_sql_expression_for_a_key(lo, span, n, offset):
|
|
223
|
+
"""For a key in range, Python's bucket assignment matches the SQL formula.
|
|
224
|
+
|
|
225
|
+
The two must agree exactly, or the walker recurses into the wrong range.
|
|
226
|
+
"""
|
|
227
|
+
hi = lo + span
|
|
228
|
+
n = min(n, span)
|
|
229
|
+
key = lo + (offset % span)
|
|
230
|
+
sql = _sql_bucket(key, lo, hi, n)
|
|
231
|
+
b_lo, b_hi = bucket_bounds(sql, lo, hi, n)
|
|
232
|
+
assert b_lo <= key < b_hi
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# max_diffs: a capped run is a prefix of the truth, never a clean bill.
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@settings(max_examples=150)
|
|
241
|
+
@given(
|
|
242
|
+
base=st.dictionaries(_key, _row, min_size=1, max_size=40),
|
|
243
|
+
limit=st.integers(min_value=1, max_value=20),
|
|
244
|
+
)
|
|
245
|
+
def test_max_diffs_never_reports_identical_when_differences_exist(base, limit):
|
|
246
|
+
"""Change every row, cap the report, and check the cap is honest.
|
|
247
|
+
|
|
248
|
+
A truncated run must carry the flag, must never read as identical, and must
|
|
249
|
+
return no more than the limit.
|
|
250
|
+
"""
|
|
251
|
+
b = {k: (v[0] + "Z", v[1]) for k, v in base.items()}
|
|
252
|
+
result = _run(base, b, max_diffs=limit)
|
|
253
|
+
|
|
254
|
+
assert len(result.diffs) <= limit
|
|
255
|
+
if len(base) > limit:
|
|
256
|
+
assert result.truncated
|
|
257
|
+
assert not result.identical
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|