1*0e16a31dSJim Jagielski# usage: cmp7.py <old_run> <new_run> <mode:strict|ext> <out.json> [-v] 2*0e16a31dSJim Jagielski# Masked structural comparison of two corpus output sets (<run>/out/*). 3*0e16a31dSJim Jagielski# Layer 1: environment/noise masks only (RAND values, dates, ids, printer config, relative-href depth, auto-style numbering). 4*0e16a31dSJim Jagielski# Layer 2: additionally applies the INTENDED edits of the fixes under test to both sides, so the remaining diff = unexplained. 5*0e16a31dSJim Jagielski# Counts, per package, how often each fix's marker is present on the old side and on the new side (exercise map input). 6*0e16a31dSJim Jagielskiimport zipfile, sys, re, os, hashlib, difflib, json, collections 7*0e16a31dSJim Jagielskiimport xml.etree.ElementTree as ET 8*0e16a31dSJim Jagielskiold, new, mode, outjson = sys.argv[1:5] 9*0e16a31dSJim Jagielskiverbose = "-v" in sys.argv 10*0e16a31dSJim JagielskiNS = dict(style="urn:oasis:names:tc:opendocument:xmlns:style:1.0", office="urn:oasis:names:tc:opendocument:xmlns:office:1.0", 11*0e16a31dSJim Jagielski text="urn:oasis:names:tc:opendocument:xmlns:text:1.0", draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0", 12*0e16a31dSJim Jagielski table="urn:oasis:names:tc:opendocument:xmlns:table:1.0", form="urn:oasis:names:tc:opendocument:xmlns:form:1.0", 13*0e16a31dSJim Jagielski chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0", meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0", 14*0e16a31dSJim Jagielski fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0", 15*0e16a31dSJim Jagielski xlink="http://www.w3.org/1999/xlink", config="urn:oasis:names:tc:opendocument:xmlns:config:1.0", 16*0e16a31dSJim Jagielski dc="http://purl.org/dc/elements/1.1/") 17*0e16a31dSJim Jagielskidef q(p, l): return "{%s}%s" % (NS[p], l) 18*0e16a31dSJim JagielskiSTYLE_NAME = q("style", "name") 19*0e16a31dSJim Jagielski 20*0e16a31dSJim Jagielskidef mask_text(d): 21*0e16a31dSJim Jagielski d = re.sub(rb'Pictures/[0-9A-F]{24,40}', b'Pictures/H', d) 22*0e16a31dSJim Jagielski d = re.sub(rb'text:change-id="ct[0-9]+"', b'text:change-id="CT"', d) 23*0e16a31dSJim Jagielski d = re.sub(rb'<text:(time|date)\b([^>]*)>[^<]*</text:\1>', rb'<text:\1\2>T</text:\1>', d) 24*0e16a31dSJim Jagielski d = re.sub(rb'office:value="[-0-9.eE+]+"', b'office:value="#"', d) 25*0e16a31dSJim Jagielski d = re.sub(rb'<text:p>[0-9.]+</text:p>', b'<text:p>#</text:p>', d) 26*0e16a31dSJim Jagielski d = re.sub(rb'(text:time-value|text:date-value|text:time|text:date)="[^"]*"', rb'\1="T"', d) 27*0e16a31dSJim Jagielski d = re.sub(rb'<meta:(creation-date|editing-duration)>[^<]*</meta:\1>', rb'<meta:\1>T</meta:\1>', d) 28*0e16a31dSJim Jagielski d = re.sub(rb'<dc:date>[^<]*</dc:date>', b'<dc:date>T</dc:date>', d) 29*0e16a31dSJim Jagielski d = re.sub(rb'(xml:id|text:id|form:id|text:continue-list)="[^"]*"', rb'\1="ID"', d) 30*0e16a31dSJim Jagielski # environment: default printer of the host, relative links depend on the output directory depth 31*0e16a31dSJim Jagielski d = re.sub(rb'(<config:config-item config:name="(?:PrinterName|PrinterSetup)"[^>]*>)[^<]*', rb'\1PRN', d) 32*0e16a31dSJim Jagielski d = re.sub(rb'(xlink:href|form:image-data)="(?:\.\./)+', rb'\1="../*/', d) 33*0e16a31dSJim Jagielski # host calendar date moved from 2026-10-22 (baseline run) to 2026-10-23 (TODAY()-style cells) 34*0e16a31dSJim Jagielski d = re.sub(rb'2026-10-2[23]', b'DATE', d) 35*0e16a31dSJim Jagielski d = re.sub(rb'10/2[23]/26', b'DATE', d) 36*0e16a31dSJim Jagielski # fields.odt: weekday name length (Donnerstag vs Freitag) changes the document character count 37*0e16a31dSJim Jagielski d = re.sub(rb'(character-count[^>]*?>|meta:character-count=")\d+', rb'\1N', d) 38*0e16a31dSJim Jagielski return d 39*0e16a31dSJim Jagielskidef isref(k): 40*0e16a31dSJim Jagielski return k.endswith("style-name") or k.endswith("page-layout-name") or k.endswith("master-page-name") or k.endswith("class-names") or k.endswith("style-names") 41*0e16a31dSJim Jagielskidef ser(e, m, defn=False, out=None): 42*0e16a31dSJim Jagielski out = [] if out is None else out 43*0e16a31dSJim Jagielski attrs = sorted((k, m.get(v, v) if isref(k) and not (defn and k == STYLE_NAME) else v) for k, v in e.attrib.items() if not (defn and k == STYLE_NAME)) 44*0e16a31dSJim Jagielski out.append("<" + e.tag + " " + " ".join(f'{k}="{v}"' for k, v in attrs) + ">" + (e.text or "").strip()) 45*0e16a31dSJim Jagielski for c in e: ser(c, m, False, out); out.append((c.tail or "").strip()) 46*0e16a31dSJim Jagielski out.append("</>") 47*0e16a31dSJim Jagielski return "".join(out) 48*0e16a31dSJim Jagielski 49*0e16a31dSJim Jagielskidef canon(root, dedupe): 50*0e16a31dSJim Jagielski autos = [c for ac in root.iter(q("office", "automatic-styles")) for c in ac if STYLE_NAME in c.attrib] 51*0e16a31dSJim Jagielski m = {} 52*0e16a31dSJim Jagielski for _ in range(4): 53*0e16a31dSJim Jagielski nm = {} 54*0e16a31dSJim Jagielski for c in autos: nm[c.get(STYLE_NAME)] = "S" + hashlib.md5(ser(c, m, True).encode()).hexdigest()[:10] 55*0e16a31dSJim Jagielski if nm == m: break 56*0e16a31dSJim Jagielski m = nm 57*0e16a31dSJim Jagielski lines = [] 58*0e16a31dSJim Jagielski def walk(e, depth): 59*0e16a31dSJim Jagielski if e.tag == q("office", "automatic-styles"): 60*0e16a31dSJim Jagielski lines.append("<automatic-styles>") 61*0e16a31dSJim Jagielski ss = sorted(ser(c, m, True) for c in e) 62*0e16a31dSJim Jagielski if dedupe: ss = sorted(set(ss)) 63*0e16a31dSJim Jagielski for s in ss: lines.append(" " + s) 64*0e16a31dSJim Jagielski lines.append("</automatic-styles>") 65*0e16a31dSJim Jagielski return 66*0e16a31dSJim Jagielski attrs = sorted((k, m.get(v, v) if isref(k) else v) for k, v in e.attrib.items()) 67*0e16a31dSJim Jagielski lines.append(" " * depth + "<" + e.tag + " " + " ".join(f'{k}="{v}"' for k, v in attrs) + ">" + ((e.text or "").strip())) 68*0e16a31dSJim Jagielski for c in e: walk(c, depth + 1) 69*0e16a31dSJim Jagielski walk(root, 0) 70*0e16a31dSJim Jagielski return lines 71*0e16a31dSJim Jagielski 72*0e16a31dSJim Jagielskidef pct(v): 73*0e16a31dSJim Jagielski m = re.fullmatch(r'(-?[0-9.]+)%', v); return float(m.group(1)) if m else None 74*0e16a31dSJim Jagielskidef length_pos(v): 75*0e16a31dSJim Jagielski m = re.fullmatch(r'(-?[0-9.]+)([a-z%]*)', v); return float(m.group(1)) > 0 if m else True 76*0e16a31dSJim Jagielski 77*0e16a31dSJim Jagielskidef normalise(root, cnt, strict, is_old): 78*0e16a31dSJim Jagielski """Apply the intended edits of the fixes (both sides). cnt[key] counts how often an edit was needed on this side.""" 79*0e16a31dSJim Jagielski parent = {c: p for p in root.iter() for c in p} 80*0e16a31dSJim Jagielski def bump(k): cnt[k] += 1 81*0e16a31dSJim Jagielski def drop_attr(e, name): del e.attrib[name] 82*0e16a31dSJim Jagielski for e in list(root.iter()): 83*0e16a31dSJim Jagielski t = e.tag; a = e.attrib 84*0e16a31dSJim Jagielski if t == q("draw", "connector") and q("svg", "viewBox") in a: drop_attr(e, q("svg", "viewBox")); bump("cc5a09edf8 viewBox") 85*0e16a31dSJim Jagielski if t in (q("text", "a"), q("text", "section-source"), q("meta", "auto-reload")): 86*0e16a31dSJim Jagielski k = {q("text", "a"): "4b1c1a1375 text:a xlink:type", q("text", "section-source"): "dd43dd10dc section-source xlink:type", q("meta", "auto-reload"): "60942d1a9c auto-reload xlink:type"}[t] 87*0e16a31dSJim Jagielski if q("xlink", "type") in a: drop_attr(e, q("xlink", "type")); bump(k) 88*0e16a31dSJim Jagielski elif q("xlink", "href") in a or t != q("text", "section-source"): bump(k + " MISSING") 89*0e16a31dSJim Jagielski if t in (q("office", "document-content"), q("office", "document-styles")) and q("office", "version") in a: 90*0e16a31dSJim Jagielski drop_attr(e, q("office", "version")); bump("c7615ea10d office:version present") 91*0e16a31dSJim Jagielski elif t in (q("office", "document-content"), q("office", "document-styles")): bump("c7615ea10d office:version MISSING") 92*0e16a31dSJim Jagielski if t == q("style", "page-layout-properties") and q("style", "layout-grid-standard-mode") in a: 93*0e16a31dSJim Jagielski drop_attr(e, q("style", "layout-grid-standard-mode")); bump("f18a0e638f layout-grid-standard-mode in page layouts") 94*0e16a31dSJim Jagielski if t == q("meta", "print-date"): 95*0e16a31dSJim Jagielski if not re.match(r'\d{4,}-\d\d-\d\dT', (e.text or "")): parent[e].remove(e); bump("150358e458 print-date bogus") 96*0e16a31dSJim Jagielski if t == q("form", "property") and q("office", "value") in a: 97*0e16a31dSJim Jagielski if a[q("office", "value")] == "": bump("e64781e76d form office:value empty") 98*0e16a31dSJim Jagielski a[q("office", "value")] = "#" 99*0e16a31dSJim Jagielski for atn in (q("draw", "opacity"), q("draw", "image-opacity")): 100*0e16a31dSJim Jagielski if atn in a: 101*0e16a31dSJim Jagielski p = pct(a[atn]) 102*0e16a31dSJim Jagielski if p is not None and (p > 100 or p < 0): bump("d47ac291e9 opacity out of range"); a[atn] = "%g%%" % max(0, min(100, p)) 103*0e16a31dSJim Jagielski if q("fo", "font-size") in a and not length_pos(a[q("fo", "font-size")]): drop_attr(e, q("fo", "font-size")); bump("efbe32fc4d font-size<=0") 104*0e16a31dSJim Jagielski for hn in ("hyphenation-push-char-count", "hyphenation-remain-char-count"): 105*0e16a31dSJim Jagielski if q("fo", hn) in a and a[q("fo", hn)] == "0": drop_attr(e, q("fo", hn)); bump("fe667bb0b4 hyphenation 0") 106*0e16a31dSJim Jagielski if q("office", "boolean-value") in a and a[q("office", "boolean-value")] not in ("true", "false"): 107*0e16a31dSJim Jagielski del a[q("office", "boolean-value")]; a[q("office", "value-type")] = "float"; a[q("office", "value")] = "#"; bump("4250607123 boolean non-0/1") 108*0e16a31dSJim Jagielski if q("table", "number-columns-spanned") in a and a[q("table", "number-columns-spanned")] == "0": drop_attr(e, q("table", "number-columns-spanned")); bump("895d935e38 colspan 0") 109*0e16a31dSJim Jagielski if q("text", "anchor-page-number") in a and a[q("text", "anchor-page-number")] == "0": drop_attr(e, q("text", "anchor-page-number")); bump("b070ffe226 anchor-page 0") 110*0e16a31dSJim Jagielski if t == q("text", "note-ref") and a.get(q("text", "reference-format")) in ("category-and-value", "caption", "value"): 111*0e16a31dSJim Jagielski a[q("text", "reference-format")] = "text"; bump("9eca93128d note-ref format") 112*0e16a31dSJim Jagielski if t == q("table", "dde-link"): 113*0e16a31dSJim Jagielski tabs = [c for c in e if c.tag == q("table", "table")] 114*0e16a31dSJim Jagielski if not tabs: bump("f8ad6c41e5 dde-link without table") 115*0e16a31dSJim Jagielski else: 116*0e16a31dSJim Jagielski tb = tabs[0] 117*0e16a31dSJim Jagielski kids = [c.tag for c in tb] 118*0e16a31dSJim Jagielski if kids == [q("table", "table-columns"), q("table", "table-rows")] and len(list(tb.iter(q("table", "table-cell")))) == 1 and not tb.attrib.get(q("table", "name")) is None and False: pass 119*0e16a31dSJim Jagielski if kids == [q("table", "table-columns"), q("table", "table-rows")] and len(list(tb.iter(q("table", "table-cell")))) == 1 and not "".join(tb.itertext()).strip() and len(list(tb.iter())) == 6 and not any(x.attrib for x in tb.iter(q("table", "table-cell"))): 120*0e16a31dSJim Jagielski e.remove(tb); bump("f8ad6c41e5 dde-link wrapper table"); 121*0e16a31dSJim Jagielski if t == q("chart", "symbol-image") and q("xlink", "href") not in a: parent[e].remove(e); bump("d6bd426c78 empty symbol-image") 122*0e16a31dSJim Jagielski if t in (q("table", "table-columns"), q("table", "table-rows")) and len(e) == 0 and parent.get(e) is not None and parent[e].tag == q("table", "table") and root.tag.endswith("document-content"): 123*0e16a31dSJim Jagielski # chart internal data (empty wrappers) 124*0e16a31dSJim Jagielski parent[e].remove(e); bump("26a3e0ceb0 empty chart table wrapper") 125*0e16a31dSJim Jagielski if strict: 126*0e16a31dSJim Jagielski norm_strict(root, cnt, parent) 127*0e16a31dSJim Jagielski 128*0e16a31dSJim JagielskiPARA_FAM = {"paragraph", "text"} 129*0e16a31dSJim Jagielskidef norm_strict(root, cnt, parent): 130*0e16a31dSJim Jagielski chart_shapes_dropped = False 131*0e16a31dSJim Jagielski for e in list(root.iter()): 132*0e16a31dSJim Jagielski t = e.tag; a = e.attrib 133*0e16a31dSJim Jagielski if t == q("style", "style") and a.get(q("style", "family")) == "paragraph": 134*0e16a31dSJim Jagielski for c in list(e): 135*0e16a31dSJim Jagielski if c.tag == q("style", "graphic-properties") and any(k.startswith("{%s}fill" % NS["draw"]) or k == q("draw", "opacity") or k.startswith("{%s}fill-" % NS["draw"]) for k in c.attrib): 136*0e16a31dSJim Jagielski e.remove(c); cnt["ef0a8aeea9 para draw:fill"] += 1 137*0e16a31dSJim Jagielski if t in (q("style", "page-layout-properties"), q("style", "header-footer-properties")): 138*0e16a31dSJim Jagielski for k in list(a): 139*0e16a31dSJim Jagielski if k.startswith("{%s}fill" % NS["draw"]) or k == q("draw", "opacity") or k.startswith("{%s}opacity" % NS["draw"]): 140*0e16a31dSJim Jagielski del a[k]; cnt["e86e06dbff page/hf draw:fill"] += 1 141*0e16a31dSJim Jagielski if t == q("style", "header-footer-properties"): 142*0e16a31dSJim Jagielski for c in list(e): 143*0e16a31dSJim Jagielski if c.tag == q("style", "background-image"): 144*0e16a31dSJim Jagielski if not c.attrib and len(c) == 0: e.remove(c); cnt["83b30299d0 hf background-image empty"] += 1 145*0e16a31dSJim Jagielski else: e.remove(c); cnt["83b30299d0 hf background-image with content"] += 1 146*0e16a31dSJim Jagielski if q("style", "layout-grid-snap-to-characters") in a: 147*0e16a31dSJim Jagielski a[q("style", "layout-grid-snap-to")] = a.pop(q("style", "layout-grid-snap-to-characters")); cnt["be94c9a190 grid-snap rename"] += 1 148*0e16a31dSJim Jagielski if t == q("style", "table-row-properties") and q("style", "keep-together") in a: del a[q("style", "keep-together")]; cnt["58c72c10be row keep-together"] += 1 149*0e16a31dSJim Jagielski if t in (q("draw", "object"), q("draw", "object-ole")): 150*0e16a31dSJim Jagielski for c in list(e): 151*0e16a31dSJim Jagielski if c.tag == q("text", "p"): 152*0e16a31dSJim Jagielski cnt["57b28c93a4 draw:object text:p " + ("with text" if "".join(c.itertext()).strip() else "empty")] += 1; e.remove(c) 153*0e16a31dSJim Jagielski if t == q("form", "combobox") and q("form", "xforms-list-source") in a: del a[q("form", "xforms-list-source")]; cnt["1028eb24a1 combobox xforms-list-source"] += 1 154*0e16a31dSJim Jagielski if t == q("chart", "chart"): 155*0e16a31dSJim Jagielski for c in list(e): 156*0e16a31dSJim Jagielski if c.tag.startswith("{%s}" % NS["draw"]): e.remove(c); cnt["edc67281bb chart additional shape " + c.tag.split("}")[1]] += 1; chart_shapes_dropped = True 157*0e16a31dSJim Jagielski if t.startswith("{%s}" % NS["style"]) and t.endswith("-properties") and q("text", "enable-numbering") in a: 158*0e16a31dSJim Jagielski del a[q("text", "enable-numbering")]; cnt["e9b28885e5 alien text:enable-numbering"] += 1 159*0e16a31dSJim Jagielski if t == q("text", "changed-region"): 160*0e16a31dSJim Jagielski for c in list(e): 161*0e16a31dSJim Jagielski if c.tag in (q("text", "insertion"),) and e.find(q("text", "insertion")) is not c: pass 162*0e16a31dSJim Jagielski # P3: successor change = an text:insertion/deletion nested in a changed-region that already carries a change; nested `office:change-info` with attribute form 163*0e16a31dSJim Jagielski for e in list(root.iter(q("office", "change-info"))): 164*0e16a31dSJim Jagielski if q("office", "chg-author") in e.attrib or q("office", "chg-date-time") in e.attrib: 165*0e16a31dSJim Jagielski p = parent.get(e) 166*0e16a31dSJim Jagielski if p is not None and p.tag in (q("text", "insertion"), q("text", "deletion"), q("text", "format-change")) and parent.get(p) is not None and len(p) == 1: 167*0e16a31dSJim Jagielski parent[p].remove(p); cnt["c8b13970b2 redline successor change-info"] += 1 168*0e16a31dSJim Jagielski elif p is not None: p.remove(e); cnt["c8b13970b2 redline successor change-info (other)"] += 1 169*0e16a31dSJim Jagielski # empty property elements left behind by dropped alien attributes 170*0e16a31dSJim Jagielski for e in list(root.iter()): 171*0e16a31dSJim Jagielski for c in list(e): 172*0e16a31dSJim Jagielski if c.tag.startswith("{%s}" % NS["style"]) and c.tag.endswith("-properties") and not c.attrib and len(c) == 0 and not (c.text or "").strip(): 173*0e16a31dSJim Jagielski e.remove(c); cnt["empty style:*-properties dropped"] += 1 174*0e16a31dSJim Jagielski if chart_shapes_dropped: 175*0e16a31dSJim Jagielski for _ in range(3): 176*0e16a31dSJim Jagielski used = {v for e in root.iter() for k, v in e.attrib.items() if isref(k)} 177*0e16a31dSJim Jagielski for ac in root.iter(q("office", "automatic-styles")): 178*0e16a31dSJim Jagielski for st in list(ac): 179*0e16a31dSJim Jagielski if st.get(STYLE_NAME) is not None and st.get(STYLE_NAME) not in used and st.tag == q("style", "style") and st.get(q("style", "family")) in ("graphic", "paragraph"): 180*0e16a31dSJim Jagielski ac.remove(st); cnt["orphan auto style after chart shapes dropped"] += 1 181*0e16a31dSJim Jagielski # drop empty paragraph auto styles and the references to them (merge / omission effect of row 1) 182*0e16a31dSJim Jagielski empty = set() 183*0e16a31dSJim Jagielski for ac in root.iter(q("office", "automatic-styles")): 184*0e16a31dSJim Jagielski for s in list(ac): 185*0e16a31dSJim Jagielski if s.tag == q("style", "style") and len(s) == 0 and set(s.attrib) <= {STYLE_NAME, q("style", "family"), q("style", "parent-style-name")} and q("style", "parent-style-name") not in s.attrib and s.get(q("style", "family")) in ("paragraph", "text", "graphic", "presentation"): 186*0e16a31dSJim Jagielski empty.add(s.get(STYLE_NAME)); ac.remove(s) 187*0e16a31dSJim Jagielski if empty: 188*0e16a31dSJim Jagielski for e in root.iter(): 189*0e16a31dSJim Jagielski for k in list(e.attrib): 190*0e16a31dSJim Jagielski if isref(k) and e.attrib[k] in empty and not (e.tag == q("style", "style")): del e.attrib[k] 191*0e16a31dSJim Jagielski 192*0e16a31dSJim Jagielskidef load(path, name, strict, dedupe, cnt, is_old, raw): 193*0e16a31dSJim Jagielski z = zipfile.ZipFile(path); data = z.read(name) 194*0e16a31dSJim Jagielski d = mask_text(data) 195*0e16a31dSJim Jagielski try: root = ET.fromstring(d) 196*0e16a31dSJim Jagielski except ET.ParseError: return [d.decode("utf8", "replace")], [d.decode("utf8", "replace")] 197*0e16a31dSJim Jagielski raw_lines = canon(ET.fromstring(d), False) 198*0e16a31dSJim Jagielski if raw: return raw_lines, None 199*0e16a31dSJim Jagielski normalise(root, cnt, strict, is_old) 200*0e16a31dSJim Jagielski return raw_lines, canon(root, dedupe) 201*0e16a31dSJim Jagielski 202*0e16a31dSJim Jagielskistrict = mode == "strict" 203*0e16a31dSJim Jagielskires = {} 204*0e16a31dSJim Jagielskitot = collections.Counter() 205*0e16a31dSJim Jagielskifor f in sorted(os.listdir(old + "/out")): 206*0e16a31dSJim Jagielski r = {"raw_diff_streams": [], "norm_diff_streams": [], "old": collections.Counter(), "new": collections.Counter()} 207*0e16a31dSJim Jagielski if not os.path.exists(new + "/out/" + f): res[f] = {"missing_in_new": True}; continue 208*0e16a31dSJim Jagielski pa, pb = old + "/out/" + f, new + "/out/" + f 209*0e16a31dSJim Jagielski za, zb = zipfile.ZipFile(pa), zipfile.ZipFile(pb) 210*0e16a31dSJim Jagielski nn = lambda z: sorted(re.sub(r'Pictures/[0-9A-F]{24,40}', 'Pictures/H', n) for n in z.namelist()) 211*0e16a31dSJim Jagielski if nn(za) != nn(zb): r["names_diff"] = sorted(set(nn(za)) ^ set(nn(zb))) 212*0e16a31dSJim Jagielski for n in sorted(za.namelist()): 213*0e16a31dSJim Jagielski if n.endswith("/") or not n.endswith(".xml") or n not in zb.namelist(): continue 214*0e16a31dSJim Jagielski ca, cb = collections.Counter(), collections.Counter() 215*0e16a31dSJim Jagielski ra, xa = load(pa, n, strict, strict, ca, True, False) 216*0e16a31dSJim Jagielski rb, xb = load(pb, n, strict, strict, cb, False, False) 217*0e16a31dSJim Jagielski r["old"].update(ca); r["new"].update(cb) 218*0e16a31dSJim Jagielski if ra != rb: r["raw_diff_streams"].append(n) 219*0e16a31dSJim Jagielski if xa != xb: 220*0e16a31dSJim Jagielski r["norm_diff_streams"].append(n) 221*0e16a31dSJim Jagielski diffl = [l[:300] for l in difflib.unified_diff(xa, xb, lineterm="", n=0) if not l.startswith(("---", "+++", "@@"))] 222*0e16a31dSJim Jagielski r.setdefault("diff_lines", {})[n] = diffl[:40] 223*0e16a31dSJim Jagielski if verbose: 224*0e16a31dSJim Jagielski print("UNEXPLAINED", f, n) 225*0e16a31dSJim Jagielski for l in diffl[:20]: print(" ", l) 226*0e16a31dSJim Jagielski r["old"] = dict(r["old"]); r["new"] = dict(r["new"]) 227*0e16a31dSJim Jagielski res[f] = r 228*0e16a31dSJim Jagielskijson.dump(res, open(outjson, "w"), indent=1) 229*0e16a31dSJim Jagielskinr = sum(1 for v in res.values() if v.get("raw_diff_streams")); nn_ = sum(1 for v in res.values() if v.get("norm_diff_streams") or v.get("names_diff") or v.get("missing_in_new")) 230*0e16a31dSJim Jagielskiprint(f"packages {len(res)}; differing after env masks: {nr}; differing after intended-edit normalisation: {nn_}") 231