Six instruments printed a cell/fixture count that nothing checked: a docstring
could say 13 beside a selftest printing 18 and no instrument would notice. Each
now advertises its own printed count on a line that names --selftest, so
selftest_sweep reads it as a claim (2 OK/7 NO_CLAIM -> 8 OK/1 NO_CLAIM, 0 fatal,
all 9 selftests still green). doc_census_check.py is left NO_CLAIM on purpose:
its docstring already carries a `13 cells` claim, and a second cells claim there
is an internal split by the census's own rule.
selftest_sweep.py's own docstring quoted a fixture line ("selftest: 7 cells ...")
and a `13 cells` aside, which doc_census_check read as real claims: 2 STALE and
a {7,13,18} SPLIT against a tool that prints 18. Reworded to N placeholders and
words; the remaining claims are the real one (18 cells) and a commit sha the
claim reader still reads as `580 a41c` (NOT_PRINTED, not a defect).
443 lines
21 KiB
Python
443 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""census_row_delta.py -- row-level diff of two doc_census_check.py censuses.
|
|
|
|
WHY THIS EXISTS
|
|
`tools/doc_census_check.py` prints a census; the number a reader quotes is its
|
|
row total. When that total moved (the digest-rev fix of 2026-10-16), the
|
|
sentence that mattered was not the total but the PARTITION: how many rows were
|
|
REMOVED, how many ADDED, how many CHANGED VERDICT. Two briefs over the same
|
|
pair of runs reached the same new total with different partitions -- nineteen
|
|
removed and three added in one, twenty removed and four added in the other --
|
|
and both are arithmetically consistent, because the partition is not a property
|
|
of the two runs alone. It is a property of the two runs AND of the key a row is
|
|
identified by. A census row is a record (tool, block, line, rev, unit, claim,
|
|
token, verdict); pick a different subset of those as the identity and you
|
|
rename a row that only moved position into a row that was deleted and another
|
|
that was created.
|
|
|
|
So this tool prints the partition under a NAMED key, never a bare "removed N",
|
|
and `--keys` prints it under every key shape it knows -- so a disagreement
|
|
about the count can be resolved to the key that produced it instead of argued
|
|
about. Every partition is then restated with position taken out of the key: a
|
|
removed row and an added row whose records agree on every field the key holds
|
|
fixed are ONE row that MOVED, reported separately from a row that was deleted
|
|
or introduced. Reading a position shift as a content change is the whole defect
|
|
class this tool exists for.
|
|
|
|
WHAT IT READS
|
|
Two JSON files written by `doc_census_check.py --json`. It does not run the
|
|
census and does not judge a census: it cannot see anything the JSON does not
|
|
carry, and it makes no claim about which census is right.
|
|
|
|
READ-ONLY. The only write path is `--json PATH`; every other mode prints.
|
|
|
|
A STATED LIMIT, NOT AN IMPLIED ONE
|
|
Rows are compared as records, so a claim whose TEXT moved between blocks (a
|
|
docstring line re-wrapped, a claim reworded) is REMOVED and ADDED, never
|
|
CHANGED. `--moves` catches the pure-position case and nothing else; a reworded
|
|
claim is reported as a delete plus a create and is not paired. The key shapes
|
|
below are the whole vocabulary this tool has for that; it invents no similarity
|
|
metric.
|
|
|
|
PASS-THROUGH, because a census leg drives every tool as `<script> --repo R --rev
|
|
REV`: `--repo` and `--rev` are accepted and IGNORED -- this tool's subject is a
|
|
pair of files, not a revision. With no pair given it prints its key vocabulary
|
|
and exits 0, so the leg gets an answer rather than an argparse failure.
|
|
|
|
USAGE
|
|
python3 census_row_delta.py A.json B.json [--moves] [--json OUT]
|
|
python3 census_row_delta.py --selftest # 33 cells, keys and partitions
|
|
"""
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import sys
|
|
|
|
# A row is identified by a subset of these fields -- the names are the JSON keys.
|
|
FIELDS = ("tool", "block", "line", "rev", "unit", "claim", "token")
|
|
|
|
# Every key shape this tool knows, in the order `--keys` prints them. The
|
|
# canonical one is the row as doc_census_check.py constructs it; the others are
|
|
# the plausible under-keyings a reader might reach for, named so that a
|
|
# disagreement about a count can be attributed rather than argued.
|
|
KEYS = {
|
|
"canonical": ("tool", "block", "line", "rev", "unit", "claim"),
|
|
"strict": ("tool", "block", "line", "rev", "unit", "claim", "token"),
|
|
"token": ("tool", "block", "line", "rev", "token", "claim"),
|
|
"merged-rev": ("tool", "block", "line", "unit", "claim"),
|
|
"no-block": ("tool", "line", "rev", "unit", "claim"),
|
|
"loose": ("tool", "line", "unit", "claim"),
|
|
"line-only": ("tool", "line"),
|
|
}
|
|
|
|
|
|
def load(path):
|
|
"""A census JSON -> {rev, repo, rows, fails, per}.
|
|
|
|
`rows` is every surviving verdict dict, each carrying its tool name; keying
|
|
happens in `keyed()` so one load serves every key shape.
|
|
"""
|
|
d = json.load(open(path, encoding="utf-8"))
|
|
rows, fails, per = [], [], {}
|
|
for rep in d["reports"]:
|
|
for v in rep["verdicts"]:
|
|
row = dict(v)
|
|
row["tool"] = rep["tool"]
|
|
rows.append(row)
|
|
for f in rep["failures"]:
|
|
fails.append((rep["tool"], f.get("block"), f.get("rev")))
|
|
per[rep["tool"]] = len(rep["verdicts"])
|
|
return {"rev": d.get("rev"), "repo": d.get("repo"), "rows": rows,
|
|
"fails": fails, "per": per}
|
|
|
|
|
|
def keyed(census, fields):
|
|
"""({key: row}, collisions) -- later duplicates win, and the count is kept.
|
|
|
|
A collision says the key is not an identity for this census: two distinct
|
|
rows share it, so the partition computed under it is a partition of ROWS
|
|
COLLAPSED, not of rows. Reported, never silent.
|
|
"""
|
|
out, collisions = {}, 0
|
|
for row in census["rows"]:
|
|
k = tuple(row.get(f) for f in fields)
|
|
if k in out:
|
|
collisions += 1
|
|
out[k] = row
|
|
return out, collisions
|
|
|
|
|
|
def diff(before, after, fields):
|
|
b, bc = keyed(before, fields)
|
|
a, ac = keyed(after, fields)
|
|
removed = sorted(set(b) - set(a))
|
|
added = sorted(set(a) - set(b))
|
|
changed = sorted(k for k in set(a) & set(b)
|
|
if b[k]["verdict"] != a[k]["verdict"])
|
|
return {"removed": removed, "added": added, "changed": changed,
|
|
"b": b, "a": a, "collisions": (bc, ac)}
|
|
|
|
|
|
def moves(before, after, d, fields):
|
|
"""What the removed/added rows are once position is taken out of the key.
|
|
|
|
Returns {pairs, candidates, ambiguous, ambiguous_min, undecided_removed,
|
|
undecided_added, unmatched_removed, unmatched_added}.
|
|
|
|
Pairs are ONE-TO-ONE and DECIDED: a signature (the row with `block` and
|
|
`line` removed from the key) that has the same number of rows on both sides.
|
|
Each pair is ONE row that MOVED, not a delete plus a create.
|
|
|
|
Where a signature has a different number of rows on each side, the tool does
|
|
NOT choose: it prints every cross candidate with its block and line delta,
|
|
records `ambiguous_min` -- how many pairings that signature could hold,
|
|
min(removed, added) -- and leaves the question open, because two removed rows
|
|
and one added row of the same shape can be a move plus a deletion or two
|
|
deletions plus a create, and the records are identical in every field this
|
|
tool can see. Resolving that needs the text the rows came from -- outside
|
|
this tool, and stated as such rather than papered over with an arbitrary
|
|
pairing. Rows inside such a signature are `undecided_*`, kept apart from the
|
|
`unmatched_*` rows that are genuinely gone or genuinely new.
|
|
|
|
AN ORDERED GUESS IS NOT A RESOLUTION. Until 2026-10-17 this branch zipped the
|
|
two sides in sorted order and reported the first rows as one MOVED row. On
|
|
the live 0031a78 -> HEAD pair that produced `MOVED 56:63 -> 89:100` for a
|
|
signature of two removed rows and one added row -- the right COUNT of moved
|
|
rows (one) and the WRONG ROW: the text says block 82 line 93 is the row that
|
|
moved to block 89 line 100, and the block 56 line 63 claim was deleted. A
|
|
count that survives a wrong identity is the defect this tool exists to catch,
|
|
so the pairing is no longer guessed at all.
|
|
|
|
Only meaningful for a key that carries `block` or `line`; with neither there
|
|
is no position to have moved from, and the empty result is the answer rather
|
|
than a guess.
|
|
"""
|
|
empty = {"pairs": [], "candidates": [], "ambiguous": [], "ambiguous_min": 0,
|
|
"undecided_removed": [], "undecided_added": [],
|
|
"unmatched_removed": [], "unmatched_added": []}
|
|
if "block" not in fields and "line" not in fields:
|
|
return empty
|
|
free = [f for f in ("block", "line") if f in fields]
|
|
ident = tuple(f for f in fields if f not in free)
|
|
rem, add = collections.defaultdict(list), collections.defaultdict(list)
|
|
for k in d["removed"]:
|
|
rem[tuple(d["b"][k].get(f) for f in ident)].append(k)
|
|
for k in d["added"]:
|
|
add[tuple(d["a"][k].get(f) for f in ident)].append(k)
|
|
out = dict(empty)
|
|
for sig in sorted(set(rem) | set(add), key=repr):
|
|
r, a = sorted(rem.get(sig, [])), sorted(add.get(sig, []))
|
|
if r and a and len(r) != len(a):
|
|
out["ambiguous"].append((sig, len(r), len(a)))
|
|
out["ambiguous_min"] += min(len(r), len(a))
|
|
out["undecided_removed"].extend(r)
|
|
out["undecided_added"].extend(a)
|
|
for k in r:
|
|
for k2 in a:
|
|
out["candidates"].append(
|
|
(k, k2, _delta(d["b"][k], d["a"][k2], "block"),
|
|
_delta(d["b"][k], d["a"][k2], "line")))
|
|
continue
|
|
n = min(len(r), len(a))
|
|
out["pairs"].extend(zip(r[:n], a[:n]))
|
|
out["unmatched_removed"].extend(r[n:])
|
|
out["unmatched_added"].extend(a[n:])
|
|
return out
|
|
|
|
|
|
def _delta(a, b, field):
|
|
"""b[field] - a[field], or None when either side does not carry it."""
|
|
if a.get(field) is None or b.get(field) is None:
|
|
return None
|
|
return b[field] - a[field]
|
|
|
|
|
|
def banner(tag, census):
|
|
return ("%-8s rev=%-12s rows=%-4d leg-failures=%-2d tools=%d"
|
|
% (tag, census["rev"], len(census["rows"]), len(census["fails"]),
|
|
len(census["per"])))
|
|
|
|
|
|
def totals(census):
|
|
return dict(sorted(collections.Counter(
|
|
r["verdict"] for r in census["rows"]).items()))
|
|
|
|
|
|
def report(before, after, name, fields, verbose=False):
|
|
d = diff(before, after, fields)
|
|
mv = moves(before, after, d, fields)
|
|
nb, na = len(before["rows"]), len(after["rows"])
|
|
print("\nkey %-11s (%s)" % (name, " + ".join(fields)))
|
|
print(" keys before %d, after %d, collisions before/after %d/%d"
|
|
% (len(d["b"]), len(d["a"]), d["collisions"][0], d["collisions"][1]))
|
|
print(" rows before %d, after %d -- removed %d, added %d, changed %d"
|
|
% (nb, na, len(d["removed"]), len(d["added"]), len(d["changed"])))
|
|
print(" arithmetic under this key: %d - %d + %d = %d %s"
|
|
% (nb, len(d["removed"]), len(d["added"]),
|
|
nb - len(d["removed"]) + len(d["added"]),
|
|
"= the after total" if nb - len(d["removed"]) + len(d["added"]) == na
|
|
else "!= the after total, because the key collides"))
|
|
if mv["pairs"] or mv["ambiguous"]:
|
|
print(" position is not content: %d row(s) of the removed/added sets are "
|
|
"ONE row at a different block/line" % len(mv["pairs"]))
|
|
for k, k2 in mv["pairs"]:
|
|
print(" MOVED %s:%s -> %s:%s %s %s %s"
|
|
% (d["b"][k].get("block"), d["b"][k].get("line"),
|
|
d["a"][k2].get("block"), d["a"][k2].get("line"),
|
|
d["b"][k].get("unit"), d["b"][k].get("claim"),
|
|
d["b"][k].get("verdict")))
|
|
for sig, nr, na2 in mv["ambiguous"]:
|
|
print(" AMBIGUOUS %s: %d removed vs %d added of one shape -- "
|
|
"%d pairing(s) possible, not decided by these records"
|
|
% (sig, nr, na2, min(nr, na2)))
|
|
print(" as CONTENT, with the DECIDED moves netted out: removed %d, added %d, "
|
|
"moved %d" % (len(d["removed"]) - len(mv["pairs"]),
|
|
len(d["added"]) - len(mv["pairs"]), len(mv["pairs"])))
|
|
amb = mv["ambiguous_min"]
|
|
if amb:
|
|
print(" %d pairing(s) the records cannot decide, so the content counts "
|
|
"are a RANGE:" % amb)
|
|
print(" every undecided row a move: removed %d, added %d, moved %d"
|
|
% (len(d["removed"]) - len(mv["pairs"]) - amb,
|
|
len(d["added"]) - len(mv["pairs"]) - amb,
|
|
len(mv["pairs"]) + amb))
|
|
print(" none of them a move: removed %d, added %d, moved %d"
|
|
% (len(d["removed"]) - len(mv["pairs"]),
|
|
len(d["added"]) - len(mv["pairs"]), len(mv["pairs"])))
|
|
if verbose:
|
|
for k in d["removed"]:
|
|
print(" REMOVED %s -> absent" % (k,))
|
|
for k in d["added"]:
|
|
print(" ADDED %s -> %s" % (k, d["a"][k]["verdict"]))
|
|
for k in d["changed"]:
|
|
print(" CHANGED %s %s -> %s" % (k, d["b"][k]["verdict"],
|
|
d["a"][k]["verdict"]))
|
|
return d
|
|
|
|
|
|
def _fixture():
|
|
"""Two synthetic censuses: one row deleted, one added, one moved, one flipped.
|
|
|
|
A fixture, not a live read: the arithmetic under test is this tool's, and a
|
|
live census changes under it. Every row carries every field the keys use.
|
|
"""
|
|
def row(tool, block, line, rev, unit, claim, verdict, token=None):
|
|
return {"tool": tool, "block": block, "line": line, "rev": rev,
|
|
"unit": unit, "claim": claim, "token": token or claim,
|
|
"verdict": verdict, "detail": "", "file": tool}
|
|
before = [row("t/a.py", 4, 10, "HEAD", "cells", 3, "MATCHES"),
|
|
row("t/a.py", 4, 11, "HEAD", "rows", 2, "NOT_PRINTED"),
|
|
row("t/b.py", 20, 40, "abc1234", "cells", 7, "STALE")]
|
|
after = [row("t/a.py", 4, 10, "HEAD", "cells", 3, "MATCHES"),
|
|
row("t/a.py", 4, 11, "HEAD", "rows", 2, "STALE"),
|
|
row("t/b.py", 21, 41, "abc1234", "cells", 7, "STALE"),
|
|
row("t/b.py", 21, 42, "HEAD", "splits", 1, "MATCHES")]
|
|
meta = {"rev": "HEAD", "repo": "/x"}
|
|
return (dict(rows=before, fails=[("t/b.py", 20, "abc1234")],
|
|
per={"t/a.py": 2, "t/b.py": 1}, **meta),
|
|
dict(rows=after, fails=[], per={"t/a.py": 2, "t/b.py": 2}, **meta))
|
|
|
|
|
|
def selftest():
|
|
before, after = _fixture()
|
|
cells, bad = [0], [0]
|
|
|
|
def check(name, got, want):
|
|
good = got == want
|
|
cells[0] += 1
|
|
bad[0] += 0 if good else 1
|
|
print("%-52s %s" % (name, "PASS" if good else "FAIL"))
|
|
if not good:
|
|
print(" got %r\n want %r" % (got, want))
|
|
|
|
d = diff(before, after, KEYS["canonical"])
|
|
mv = moves(before, after, d, KEYS["canonical"])
|
|
check("canonical: removed rows", len(d["removed"]), 1)
|
|
check("canonical: added rows (the moved row is one of them)", len(d["added"]), 2)
|
|
check("canonical: changed verdicts", len(d["changed"]), 1)
|
|
check("canonical: the move is paired",
|
|
[(d["b"][k]["block"], d["b"][k]["line"], d["a"][k2]["block"],
|
|
d["a"][k2]["line"]) for k, k2 in mv["pairs"]],
|
|
[(20, 40, 21, 41)])
|
|
check("canonical: exactly one pair, the genuinely new row is not one",
|
|
len(mv["pairs"]), 1)
|
|
check("canonical: no removed row is left unmatched",
|
|
mv["unmatched_removed"], [])
|
|
check("canonical: one added row is left unmatched",
|
|
len(mv["unmatched_added"]), 1)
|
|
check("canonical: nothing ambiguous here", mv["ambiguous"], [])
|
|
check("canonical: content = raw minus the move",
|
|
(len(d["removed"]) - len(mv["pairs"]), len(d["added"]) - len(mv["pairs"])),
|
|
(0, 1))
|
|
check("canonical: partition arithmetic closes",
|
|
len(before["rows"]) - len(d["removed"]) + len(d["added"]),
|
|
len(after["rows"]))
|
|
d2 = diff(before, after, KEYS["merged-rev"])
|
|
check("merged-rev: dropping rev moves nothing here",
|
|
(len(d2["removed"]), len(d2["added"]), len(d2["changed"])), (1, 2, 1))
|
|
d3 = diff(before, after, KEYS["line-only"])
|
|
m3 = moves(before, after, d3, KEYS["line-only"])
|
|
check("line-only: removed", len(d3["removed"]), 1)
|
|
check("line-only: added", len(d3["added"]), 2)
|
|
# 1 removed vs 2 added at one line: the old code zipped them and called
|
|
# (40 -> 41) a move. Nothing in the records says which of the two added rows
|
|
# is the shifted one, so the pairing is UNDECIDED and both are printed.
|
|
check("line-only: a 1-vs-2 signature is left undecided, not paired in order",
|
|
len(m3["pairs"]), 0)
|
|
check("line-only: every candidate is printed, neither chosen",
|
|
sorted((d3["b"][k]["line"], d3["a"][k2]["line"])
|
|
for k, k2, _db, _dl in m3["candidates"]),
|
|
[(40, 41), (40, 42)])
|
|
check("line-only: the undecided pairing is counted", m3["ambiguous_min"], 1)
|
|
check("line-only: no removed row is called gone", m3["unmatched_removed"], [])
|
|
check("line-only: no added row is called new", m3["unmatched_added"], [])
|
|
check("line-only: its rows are the undecided ones",
|
|
(len(m3["undecided_removed"]), len(m3["undecided_added"])), (1, 2))
|
|
dup = {"rows": before["rows"] + [dict(before["rows"][2], block=19, line=39)],
|
|
"fails": [], "per": {}, "rev": "HEAD", "repo": "/x"}
|
|
d4 = diff(dup, after, KEYS["canonical"])
|
|
m4 = moves(dup, after, d4, KEYS["canonical"])
|
|
check("many-to-one: one ambiguous signature reported",
|
|
[(nr, na) for _sig, nr, na in m4["ambiguous"]], [(2, 1)])
|
|
check("many-to-one: nothing is paired on the tool's guess", len(m4["pairs"]), 0)
|
|
check("many-to-one: the undecided pairing is counted", m4["ambiguous_min"], 1)
|
|
check("many-to-one: both removed rows are undecided, neither called gone",
|
|
len(m4["undecided_removed"]), 2)
|
|
check("many-to-one: the added row of that shape is undecided too",
|
|
len(m4["undecided_added"]), 1)
|
|
check("many-to-one: nothing is left unmatched on the removed side",
|
|
len(m4["unmatched_removed"]), 0)
|
|
check("many-to-one: the genuinely new row is the unmatched one",
|
|
len(m4["unmatched_added"]), 1)
|
|
check("many-to-one: the candidates carry their block and line deltas",
|
|
sorted((db, dl) for _k, _k2, db, dl in m4["candidates"]),
|
|
[(1, 1), (2, 2)])
|
|
check("many-to-one: content bounds -- moved",
|
|
(len(m4["pairs"]), len(m4["pairs"]) + m4["ambiguous_min"]), (0, 1))
|
|
check("many-to-one: content bounds -- removed",
|
|
(len(d4["removed"]) - len(m4["pairs"]) - m4["ambiguous_min"],
|
|
len(d4["removed"]) - len(m4["pairs"])), (1, 2))
|
|
check("keyed: a key that is not an identity collides",
|
|
keyed(before, ("tool",))[1], 1)
|
|
check("keyed: collided key count", len(keyed(before, ("tool",))[0]), 2)
|
|
check("totals: before", totals(before),
|
|
{"MATCHES": 1, "NOT_PRINTED": 1, "STALE": 1})
|
|
check("totals: after", totals(after), {"MATCHES": 2, "STALE": 2})
|
|
print("selftest: %d cells, %d passed, %d failed"
|
|
% (cells[0], cells[0] - bad[0], bad[0]))
|
|
return 1 if bad[0] else 0
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("before", nargs="?", help="earlier census JSON")
|
|
ap.add_argument("after", nargs="?", help="later census JSON")
|
|
ap.add_argument("--key", default="canonical", choices=sorted(KEYS))
|
|
ap.add_argument("--keys", action="store_true",
|
|
help="print the partition under every key shape")
|
|
ap.add_argument("--verbose", action="store_true")
|
|
ap.add_argument("--selftest", action="store_true")
|
|
ap.add_argument("--json", help="write the partitions it printed")
|
|
# accepted and ignored: see the pass-through note in the module docstring
|
|
ap.add_argument("--repo", help=argparse.SUPPRESS)
|
|
ap.add_argument("--rev", help=argparse.SUPPRESS)
|
|
a = ap.parse_args()
|
|
|
|
if a.selftest:
|
|
return selftest()
|
|
if not (a.before and a.after):
|
|
print("census_row_delta -- no census pair given; key shapes available:")
|
|
for name in sorted(KEYS):
|
|
print(" %-11s %s" % (name, " + ".join(KEYS[name])))
|
|
return 0
|
|
|
|
before, after = load(a.before), load(a.after)
|
|
print(banner("before", before))
|
|
print(banner("after", after))
|
|
print("verdict totals before: %s" % totals(before))
|
|
print("verdict totals after: %s" % totals(after))
|
|
print("\nrows per tool: before -> after")
|
|
for t in sorted(set(before["per"]) | set(after["per"])):
|
|
print(" %-40s %s -> %s" % (t, before["per"].get(t), after["per"].get(t)))
|
|
bf, af = set(before["fails"]), set(after["fails"])
|
|
if bf or af:
|
|
print("\nleg failures: before %d, after %d (%d gone, %d new)"
|
|
% (len(bf), len(af), len(bf - af), len(af - bf)))
|
|
|
|
out = {}
|
|
for name in (sorted(KEYS) if a.keys else [a.key]):
|
|
d = report(before, after, name, KEYS[name], a.verbose)
|
|
mv = moves(before, after, d, KEYS[name])
|
|
amb = mv["ambiguous_min"]
|
|
out[name] = {"fields": list(KEYS[name]), "removed": len(d["removed"]),
|
|
"added": len(d["added"]), "changed": len(d["changed"]),
|
|
"moved": len(mv["pairs"]), "undecided_pairings": amb,
|
|
"moved_range": [len(mv["pairs"]), len(mv["pairs"]) + amb],
|
|
"removed_content_range": [
|
|
len(d["removed"]) - len(mv["pairs"]) - amb,
|
|
len(d["removed"]) - len(mv["pairs"])],
|
|
"added_content_range": [
|
|
len(d["added"]) - len(mv["pairs"]) - amb,
|
|
len(d["added"]) - len(mv["pairs"])],
|
|
"unmatched_removed": len(mv["unmatched_removed"]),
|
|
"unmatched_added": len(mv["unmatched_added"])}
|
|
if a.verbose or a.keys:
|
|
for k in d["removed"]:
|
|
print(" -%s %s" % (k, d["b"][k]["verdict"]))
|
|
for k in d["added"]:
|
|
print(" +%s %s" % (k, d["a"][k]["verdict"]))
|
|
if a.json:
|
|
with open(a.json, "w", encoding="utf-8") as fh:
|
|
json.dump({"before": a.before, "after": a.after,
|
|
"before_rev": before["rev"], "after_rev": after["rev"],
|
|
"rows_before": len(before["rows"]),
|
|
"rows_after": len(after["rows"]),
|
|
"partitions": out}, fh, indent=1)
|
|
print("\nwrote %s" % a.json)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|