Files

1653 lines
71 KiB
Python

#!/usr/bin/env python3
"""Compare converter output with reference IDML files.
For every INDD file in the corpus that has a reference IDML from the same
major version (the `idml` column of corpus/inventory.tsv, written by
tools/inventory.py), convert it with `target/release/indd` and compare the
result with the reference. Files with the same SHA-256 are converted
once. Reports, per element type: how many referenced elements we produce
(matched by Self), and per attribute how often our value matches. Also
reports story text agreement.
Usage: python3 -I tools/compare.py [--limit N] [--detail TAG]... [--file SUBSTR]
[--schemas DIR --jing DIR] [--bin PATH]
[--all] [--exclude PREFIX]... [--jobs N]
[--shortfalls N] [--trusted] [--stale N]
[--gaps N] [--out DIR] [--cache DIR]
[--valid-cache FILE] [--subtotal PREFIX]...
Run from the repository root after `cargo build --release`. With --schemas
and --jing, also validates every output with tools/validate.sh, in batches
of VALIDATE_BATCH files (one Jing run per schema for a whole batch). --bin runs
another converter binary, for example a copy of the previous build.
Conversions run in --jobs threads and the comparisons of pairs in --jobs
worker processes.
--cache DIR keeps every conversion in DIR/<first 16 hex digits of the
binary's SHA-256>/, named by cache_key (the SHA-256 of the INDD file and
a short hash of its file name): the package (.idml), a record of the exit
status, stderr and the package's entries (.json), and for pairs the output
of `indd xmp` and `indd uids`. A later run with the same binary reuses them
instead of converting. A run killed by a signal or a timeout is not kept.
It also keeps the result of each pair's comparison in DIR/compare/, named
by a SHA-256 of everything the result depends on (comparison_key): the
reference IDML, the output package, the xmp and uids outputs, the pair's
path and file name, --show, the source of this file and the Python
version. A later run, with any binary, reuses it for a pair whose output
did not change.
--valid-cache FILE keeps schema validation results by part (schema and
SHA-256 of the part; for the same schemas, Jing and tools/validate.sh) and
validates only parts it does not list, in reduced packages; the results
are the same as validating every package.
--subtotal PREFIX also reports the pairs whose path does not start with
PREFIX (conversion failures, schema failures, story text, value coverage)
and adds them to summary.json under "subtotals".
--all also converts every other INDD and INDT file under corpus/ (any
version, either byte order, without a usable IDML), validates the output
if schemas are given, and reports failures for those files separately.
Files the converter rejects for a correct reason (not an INDD file,
InDesign 1.x, truncated, no object database: REJECTIONS) are reported as
rejected files, not as conversion failures.
--exclude leaves out files whose path under corpus/ starts with PREFIX.
Converter warnings are counted by kind over all converted files.
Pairs are trustworthy or stale (the IDML shows a different save than the
INDD; rule and evidence in docs/measurement.md). Totals are printed for all
pairs and for trustworthy pairs; --trusted limits the element tables,
shortfalls and details to trustworthy pairs, --stale N lists stale pairs.
Last comes the headline (docs/measurement.md): value coverage, the share
of reference values the conversion reproduces, over trustworthy pairs and
over all pairs; document scores (pairs with coverage >= 99 % and >= 99.9 %);
extra values (values the output has and the reference does not); and the
biggest gaps (--gaps N). pairs.tsv, gaps.tsv and gaps-all.tsv go to --out
(default target/compare), with values.tsv and values-all.tsv, which list
every key with its values and how many are reproduced, and extras.tsv and
extras-all.tsv, which list the extra values by key. summary.json holds the
numbers of the README's "Current numbers" section, which
tools/readme_numbers.py writes into the README.
--shortfalls N lists the N element types and attributes that fall short
(missing elements; wrong or missing attribute values; differing story
text) in the most documents.
Embedded file data (`Contents`) is compared by digest, so it is reported as
`md5:<hex> <length>` rather than as the full text.
"""
import argparse
import fnmatch
import functools
import gc
import hashlib
import json
import multiprocessing
import os
import pickle
import re
import subprocess
import sys
import tempfile
import threading
import time
import xml.etree.ElementTree as ET
import zipfile
from collections import Counter, defaultdict
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BIN = ROOT / "target" / "release" / "indd"
# Converted files validated together in one tools/validate.sh run.
VALIDATE_BATCH = 600
# Error messages of files the converter rejects for a correct reason:
# they are not INDD files it can read. Start of the message, and how the
# report names them.
REJECTIONS = [
("not an INDD file", "not INDD files"),
("not supported yet: InDesign 1.x", "InDesign 1.x files"),
("file truncated", "truncated"),
("no object database", "without an object database"),
]
def error_message(stderr):
"""The error of a failed conversion: the last line that is not a
warning."""
lines = [x for x in stderr.splitlines() if not x.startswith("warning: ")]
return lines[-1] if lines else ""
def split_rejected(failed):
"""(rejected, failures) of a list of (name, stderr)."""
rejected = [f for f in failed
if any(error_message(f[1]).startswith(p) for p, _ in REJECTIONS)]
return rejected, [f for f in failed if f not in rejected]
def rejection_kinds(rejected):
"""The rejected files counted by kind, as text."""
kinds = Counter(next(n for p, n in REJECTIONS if error_message(err).startswith(p))
for _, err in rejected)
return ", ".join(f"{n:,} {name}" for name, n in kinds.most_common())
def corpus_path(path):
"""A path under corpus/ as a string relative to corpus/, like
str(path.relative_to(...)), which takes several times as long."""
prefix, s = str(ROOT / "corpus") + os.sep, str(path)
if not s.startswith(prefix):
raise ValueError(f"{path} is not under {ROOT / 'corpus'}")
return s[len(prefix):]
def inventory():
"""Rows of corpus/inventory.tsv (tools/inventory.py), as dicts."""
lines = (ROOT / "corpus" / "inventory.tsv").read_text().splitlines()
cols = lines[0].split("\t")
return [dict(zip(cols, line.split("\t"))) for line in lines[1:]]
def known_digests():
"""Path under corpus/ -> (SHA-256, size in bytes) of the files the
inventory lists with a digest, for digest()."""
return {r["path"]: (r["sha256"], r.get("bytes")) for r in inventory() if r.get("sha256")}
def digest(indd, known):
"""SHA-256 of a corpus file. The inventory's digest is taken when the
inventory lists the file with its present size (the inventory records no
modification time); otherwise the file is read."""
path = corpus_path(indd)
sha, size = known.get(path, (None, None))
if sha and size == str(indd.stat().st_size):
return sha
return file_sha256(indd)
def cache_key(indd, sha):
"""The name of a conversion under a --cache folder: the INDD's SHA-256
and a short hash of its file name, which the output contains
(designmap.xml's Name)."""
return f"{sha}-{hashlib.sha256(os.fsencode(indd.name)).hexdigest()[:8]}"
def pairs(limit, substr, exclude, seen, known):
"""Corpus files with a same-version reference IDML, without duplicates.
Adds the digest of each file to `seen`."""
out = []
for row in inventory():
path, order, ver, dom = row["path"], row["order"], row["version"], row["idml_dom"]
if order != "LE" or not dom or ver.split(".")[0] != dom.split(".")[0]:
continue
if substr and substr not in path or any(path.startswith(e) for e in exclude):
continue
indd = ROOT / "corpus" / path
d = digest(indd, known)
if d in seen:
continue
seen.add(d)
idml = row.get("idml") or str(Path(path).with_suffix(".idml"))
out.append((indd, ROOT / "corpus" / idml))
return out[:limit] if limit else out
def unpaired(substr, exclude, seen, known):
"""Every other INDD and INDT file under corpus/, without duplicates."""
out = []
for indd in sorted((ROOT / "corpus").rglob("*")):
if indd.suffix.lower() not in (".indd", ".indt") or not indd.is_file():
continue
path = corpus_path(indd)
if substr and substr not in path or any(path.startswith(e) for e in exclude):
continue
d = digest(indd, known)
if d in seen:
continue
seen.add(d)
out.append(indd)
return out
def warning_kind(msg):
"""A warning with its numbers and quoted names replaced, for counting."""
msg = re.sub(r'"[^"]*"', '"…"', msg)
msg = re.sub(r"\b0x[0-9a-fA-F]+\b", "#", msg)
return re.sub(r"\b\d+(\.\d+)?\b", "#", msg)
XMP_NS = {"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"xmp": "http://ns.adobe.com/xap/1.0/",
"xmpMM": "http://ns.adobe.com/xap/1.0/mm/",
"stRef": "http://ns.adobe.com/xap/1.0/sType/ResourceRef#",
"stEvt": "http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"}
# A pair is stale if the INDD was saved more than this long after the
# IDML's metadata date (docs/measurement.md). A PDF is a different save if
# its date and the INDD's are further apart than this.
STALE_AFTER = timedelta(hours=1)
def xmp_descriptions(packet):
"""The rdf:Description elements of the document in an XMP packet."""
if not packet:
return []
start = packet.find(b"<x:xmpmeta")
end = packet.rfind(b"</x:xmpmeta>")
if start < 0 or end < 0:
return []
try:
meta = ET.fromstring(packet[start:end + len(b"</x:xmpmeta>")])
except ET.ParseError:
return []
return meta.findall("rdf:RDF/rdf:Description", XMP_NS)
def xmp_value(el, name):
"""A simple XMP property of an element, written as an attribute or as a
child element (`name` is "prefix:Local")."""
prefix, local = name.split(":")
key = "{%s}%s" % (XMP_NS[prefix], local)
text = el.get(key) or el.findtext(key)
return text.strip() if text else None
def modify_date(packet):
"""xmp:ModifyDate of the document in an XMP packet, or None."""
for desc in xmp_descriptions(packet):
text = xmp_value(desc, "xmp:ModifyDate")
if text:
try:
d = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
# Without a time zone, compare clock times.
return d.replace(tzinfo=d.tzinfo or timezone.utc)
return None
def xmp_instances(packet):
"""(xmpMM:InstanceID, instance ID of xmpMM:DerivedFrom, instance IDs of
the xmpMM:History events in order) of the document in an XMP packet.
Missing values are None; the history is a list."""
instance = derived = None
history = []
for desc in xmp_descriptions(packet):
instance = instance or xmp_value(desc, "xmpMM:InstanceID")
df = desc.find("xmpMM:DerivedFrom", XMP_NS)
if df is not None and derived is None:
derived = xmp_value(df, "stRef:instanceID")
h = desc.find("xmpMM:History", XMP_NS)
if h is not None:
history += [xmp_value(li, "stEvt:instanceID")
for li in h.iter("{%s}li" % XMP_NS["rdf"])]
return instance, derived, [i for i in history if i]
def pdf_stale_reasons(indd_xmp, pdf_xmp, pdf_pages, idml_pages, pdf_creator):
"""Why a PDF beside an INDD may not show the same save as the INDD
(empty if there is no sign of that). `indd_xmp` and `pdf_xmp` are XMP
packets; `pdf_creator` is the PDF's Creator entry. See
docs/measurement.md, "Same-save PDFs", for the evidence."""
out = []
instance, _, history = xmp_instances(indd_xmp)
derived = xmp_instances(pdf_xmp)[1]
if derived is None:
out.append("PDF names no source instance")
elif derived != instance and derived not in history:
out.append("PDF source instance not in the INDD history")
indd_date, pdf_date = modify_date(indd_xmp), modify_date(pdf_xmp)
if indd_date is None or pdf_date is None:
out.append("no ModifyDate")
elif abs(pdf_date - indd_date) > STALE_AFTER:
out.append("PDF and INDD saved over an hour apart")
if pdf_pages is None or idml_pages is None or pdf_pages != idml_pages:
out.append("page count differs")
if not (pdf_creator or "").startswith("Adobe InDesign"):
out.append("PDF not written by InDesign")
return out
def idml_modify_date(idml):
"""xmp:ModifyDate in an IDML package's META-INF/metadata.xml, or None."""
try:
with zipfile.ZipFile(idml) as z:
return modify_date(z.read("META-INF/metadata.xml"))
except (KeyError, zipfile.BadZipFile, OSError):
return None
def stale_reasons(indd_date, idml_date, indd_uids, ref_el, ref_st, our_st):
"""Why a pair's IDML may not show the same save as its INDD (empty if
there is no sign of that). `indd_uids` maps each UID in the INDD
database to its class (None for a slot without a class). See
docs/measurement.md for the evidence."""
out = []
if indd_date is None or idml_date is None:
out.append("no ModifyDate")
elif indd_date < idml_date:
out.append("IDML modified after the INDD")
elif indd_date - idml_date > STALE_AFTER:
out.append("INDD saved over an hour after the IDML")
ref_uids = {int(s[1:], 16) for _, s in ref_el if re.fullmatch(r"u[0-9a-f]+", s)}
if indd_uids is not None and ref_uids - indd_uids.keys():
out.append("IDML objects not in the INDD")
if indd_uids is not None and any(indd_uids.get(u, 0) is None for u in ref_uids):
out.append("IDML objects deleted in the INDD")
if set(our_st) - set(ref_st):
out.append("INDD stories not in the IDML")
return out
def validate(outs, args):
"""Schema errors of outputs, as {path: [messages]} (valid outputs are
left out). Runs tools/validate.sh once over all of them; it batches the
Jing runs."""
errs = defaultdict(list)
if not outs:
return errs
env = dict(os.environ, VALIDATE_JOBS=str(max(1, args.jobs)))
v = subprocess.run(
[ROOT / "tools" / "validate.sh", *map(str, outs), args.schemas, args.jing],
capture_output=True, text=True, env=env)
for line in v.stdout.splitlines():
path, _, msg = line.partition("\t")
errs[path].append(msg)
if v.returncode not in (0, 1) or v.returncode == 1 and not errs or "jing" in errs:
raise SystemExit(f"tools/validate.sh failed: {v.stderr.strip()} {errs.get('jing')}")
return errs
# Elements that wrap part of a story's text without being text themselves.
# IDML puts them around ranges (ParagraphStyleRange > HyperlinkTextSource >
# CharacterStyleRange, XMLElement > ParagraphStyleRange) or inside a range
# around text; text_ranges looks through them (docs/measurement.md).
WRAPPERS = {"XMLElement", "HyperlinkTextSource", "CrossReferenceSource", "Change",
"EndnoteRange"}
# Children that are neither text nor a character position.
NOT_TEXT = {"Properties", "XMLAttribute"}
# Elements that hold text of their own inside a story: their ranges are
# counted in a text flow of their own (docs/measurement.md).
NESTED = {"Footnote", "Cell", "Note"}
ERROR = re.compile(r"(?P<part>[^:]+):(?P<line>\d+):(?P<col>\d+): error: (?P<msg>.*)")
# Form fields that InDesign writes inside a character range when they are
# anchored in text, where the schema does not allow them.
ANCHORED_FORM_FIELDS = ("CheckBox", "RadioButton", "TextBox", "ComboBox", "ListBox",
"SignatureField")
def endnote_errors(idml, errs):
"""The schema errors of `idml` as (part, message) without positions,
if every one is a deviation of InDesign's own markup from the 21.5
schema: an `Endnote` in a character range, an element inside an
`EndnoteRange`, or a form field anchored in a character range. None
otherwise (docs/measurement.md)."""
out = set()
z = zipfile.ZipFile(idml)
for e in errs:
m = ERROR.match(e)
if not m:
return None
part, line, msg = m["part"], int(m["line"]), m["msg"]
form = any(msg.startswith(f'element "{t}" not allowed here')
for t in ANCHORED_FORM_FIELDS)
if not msg.startswith('element "Endnote" not allowed here') and \
not (form and endnote_parent(z.read(part), line) == "CharacterStyleRange") and \
endnote_parent(z.read(part), line) != "EndnoteRange":
return None
out.add((part, msg))
return out
def endnote_parent(data, line):
"""The tag of the parent of the first element that starts on `line`."""
import xml.parsers.expat
stack, found = [], []
p = xml.parsers.expat.ParserCreate()
def start(tag, attrs):
if not found and p.CurrentLineNumber == line:
found.append(stack[-1] if stack else None)
stack.append(tag)
p.StartElementHandler = start
p.EndElementHandler = lambda tag: stack.pop()
p.Parse(data, True)
return found[0] if found else None
def text_ranges(story):
"""Map (text flow, offset) -> attributes of the paragraph and character
range starting there, with keys prefixed PSR. and CSR. Flow 0 is the
story's own text; each footnote, table cell and note is a flow of its
own, numbered in document order. Ranges inside wrapper elements count,
at whatever depth. Offsets count Content in UTF-16 code units and every
other element inside a character range (Br, Table, Footnote, an
anchored frame) as one position; elements outside character ranges
(StoryPreference, ...) are not text."""
out = {}
flows = 0
def walk(el, pa, in_csr, flow, pos):
"""Walk the children of `el` inside the paragraph attributes `pa`;
`in_csr` tells whether `el` is inside a character range. Returns
the offset after them."""
for ch in el:
if ch.tag in NOT_TEXT:
continue
if ch.tag == "ParagraphStyleRange":
pos = walk(ch, {"PSR." + k: v for k, v in props(ch).items()}, False, flow, pos)
elif ch.tag == "CharacterStyleRange":
start = pos
pos = walk(ch, pa, True, flow, pos)
if pos > start:
ca = {"CSR." + k: v for k, v in props(ch).items()}
# A range nested at the same offset (Change > range at
# the start of this one) keeps the offset.
out.setdefault((flow, start), {**pa, **ca})
elif ch.tag in WRAPPERS:
pos = walk(ch, pa, in_csr, flow, pos)
elif ch.tag == "Content":
pos += len((ch.text or "").encode("utf-16-le")) // 2
else:
if in_csr:
pos += 1
nested(ch)
return pos
def nested(el):
"""Walk the text flows held in `el` (a table's cells, a footnote)."""
nonlocal flows
if el.tag in NESTED:
flows += 1
walk(el, {}, False, flows, 0)
return
for ch in el:
if ch.tag != "Properties":
nested(ch)
walk(story, {}, False, 0, 0)
return out
def load(path):
"""Map Self -> element, plus story texts, from an IDML package."""
z = zipfile.ZipFile(path)
elements = {}
stories = {}
ranges = {}
for name in z.namelist():
if not name.endswith(".xml"):
continue
try:
root = ET.fromstring(z.read(name))
except ET.ParseError as e:
raise SystemExit(f"{path}: {name}: {e}")
for el in root.iter():
s = el.get("Self")
if s is not None:
if el.tag == "Font":
s = font_self(el, s)
elements.setdefault((el.tag, s), el)
# Children without Self (TextFramePreference, ...) are
# compared as "<parent tag>/<child tag>" of the parent.
for ch in el:
if ch.get("Self") is not None or ch.tag == "Properties":
continue
if len(ch.attrib):
elements.setdefault((f"{el.tag}/{ch.tag}", s), ch)
nested_options(elements, f"{el.tag}/{ch.tag}", s, ch)
else:
# A container without attributes (TransparencySetting):
# compare its children as "<parent>/<child>/<grandchild>".
for g in ch:
if g.get("Self") is None and len(g.attrib):
elements.setdefault((f"{el.tag}/{ch.tag}/{g.tag}", s), g)
if name == "Resources/Preferences.xml":
for el in root:
elements.setdefault((el.tag, "Preferences"), el)
nested_options(elements, el.tag, "Preferences", el)
if name.startswith("Stories/"):
for st in root.iter("Story"):
stories[st.get("Self")] = story_text(st)
ranges[st.get("Self")] = text_ranges(st)
return elements, stories, ranges
# Children of a child element with attributes that are compared as
# "<parent>/<child>/<grandchild>" (docs/measurement.md, what is compared).
NESTED_OPTIONS = {("TextWrapPreference", "ContourOption")}
def nested_options(elements, tag, s, el):
for g in el:
if (el.tag, g.tag) in NESTED_OPTIONS and len(g.attrib):
elements.setdefault((f"{tag}/{g.tag}", s), g)
def story_text(story):
parts = []
for el in story.iter():
if el.tag == "Content":
parts.append(el.text or "")
elif el.tag == "Br":
parts.append("\n")
return "".join(parts)
def norm(v):
"""Normalise numbers so 1 == 1.0 and tiny float noise is ignored."""
def fix(tok):
try:
f = float(tok)
except ValueError:
return tok
return f"{f:.6g}"
return " ".join(fix(t) for t in v.split(" "))
def canon(el):
"""A structured property (TabList, AllNestedStyles, ...) as one string:
each leaf as tag=text with numbers normalised, in document order."""
if len(el) == 0 and el.get("type") != "list":
return f"{el.tag}={norm(el.text or '')}"
return el.tag + "[" + ",".join(canon(c) for c in el) + "]"
def props(el):
"""Attributes plus <Properties> children (as P.Name)."""
a = dict(el.attrib)
p = el.find("Properties")
if p is not None:
for c in p:
if c.get("type") == "list":
a["P." + c.tag] = canon(c)
elif len(c) == 0:
text = c.text or ""
# A line feed and tabs alone are the indentation of an
# element written open and closed (docs/measurement.md).
if re.fullmatch(r"\n\t*", text):
text = ""
extra = sorted((k, v) for k, v in c.attrib.items() if k != "type")
if extra:
text = " ".join(f"{k}={v}" for k, v in extra)
if c.tag == "Contents":
n = norm(text)
text = f"md5:{hashlib.md5(n.encode()).hexdigest()} {len(n)}"
a["P." + c.tag] = text
return a
# Gap keys for values that could not be compared one by one.
NO_ELEMENT = "(element not produced)"
NO_TEXT = "(story text differs)"
NOT_IN_REFERENCE = "(element not in reference)"
ELEMENT = "(element)"
# Values left out of value coverage because they cannot be derived from an
# INDD file: (tag pattern, attribute or P.<Properties child> pattern,
# reason), patterns as in fnmatch. docs/measurement.md explains each entry.
EXCLUDED = [
("Font", "Status", "whether the font is installed on the exporting computer"),
("Document/{http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging}*", "src",
"file names of the package parts, chosen by the IDML writer"),
("DocumentUser", "P.UserColor",
"colour the exporting InDesign gives each user, not the colour in the INDD"),
("Assignment", "Name",
"the exporting InDesign's name for unassigned content, which depends on its language"),
("Link", "LinkImportTime", "local time of the exporting computer; the INDD stores UTC"),
("Link", "LinkImportModificationTime",
"local time of the exporting computer; the INDD stores UTC"),
("*", "BitmapPrinting", "evaluated by the exporting computer; not stored in the INDD"),
] + [
("Font", k, "axes of the font file on the exporting computer; not stored in the INDD")
for k in ("NumDesignAxes", "DesignAxesName", "DesignAxesValues", "P.DesignAxesRange")
] + [
("*", k, "written by a plugin of the exporting InDesign; not in the IDML schema")
for k in ("Observers", "Subjects", "DisplayErrorDialogs", "EventFilter", "JavaScript",
"SubjectScriptTagFilter", "UseDebugger", "CompactedDataStore", "ComponentId")
] + [
("Story", "P.DocsFlowImportSettings",
"written by a plugin of the exporting InDesign; not in the IDML schema"),
("Story", "P.WordsFlow*",
"written by a plugin of the exporting InDesign; not in the IDML schema"),
# ELEMENT leaves out the whole element, with all its values.
("Document/WordsFlowPreference", ELEMENT,
"written by a plugin of the exporting InDesign; not in the IDML schema"),
]
# Attributes of a Link that describe the linked file as InDesign found it
# when it exported the IDML. When the IDML's LinkResourceURI differs from
# the converter's (which comes from the INDD), the link was resolved to
# another file on the exporting computer, and these values of that link
# are left out (docs/measurement.md).
RELINKED = re.compile(r"LinkResource.*|LinkImport.*")
# Font technology suffix that IDML adds to some family names, depending on
# the fonts installed on the exporting computer (docs/measurement.md).
FONT_SUFFIX = re.compile(r" \((OTF|TT|T1)\)$")
def font_name(tag, key, v, family=None):
"""A value with the font technology suffix removed if it names a font
family: FontFamily Name, Font FontFamily, AppliedFont and BulletsFont.
A Font's Name starts with its family (passed as family)."""
if (tag, key) in (("FontFamily", "Name"), ("Font", "FontFamily")) or \
key.endswith(("P.AppliedFont", "P.BulletsFont")):
return FONT_SUFFIX.sub("", v)
if (tag, key) == ("Font", "Name") and family and v.startswith(family):
return FONT_SUFFIX.sub("", family) + v[len(family):]
return v
def font_self(el, s):
"""A Font's Self with the technology suffix removed from the family
name it contains, so that fonts match whether or not the exporting
computer added the suffix."""
family = el.get("FontFamily") or ""
base = FONT_SUFFIX.sub("", family)
if base != family:
return s.replace("Fontn" + family, "Fontn" + base, 1)
return s
@functools.cache
def excluded(tag, key):
return any(fnmatch.fnmatchcase(key, k) and fnmatch.fnmatchcase(tag, t)
for t, k, _ in EXCLUDED)
def tree(el):
"""An element with its attributes, text and descendants as one string,
numbers normalised: the value of a structured <Properties> child such as
PathGeometry."""
attrs = ",".join(f"{k}={norm(v)}" for k, v in sorted(el.attrib.items()) if k != "type")
kids = ",".join(tree(c) for c in el)
return f"{el.tag}({attrs})[{kids}]{norm((el.text or '').strip())}"
def values(tag, el):
"""The values of an element that value coverage counts: attributes and
<Properties> children, without Self and without EXCLUDED values."""
a = props(el)
p = el.find("Properties")
if p is not None:
for c in p:
if c.get("type") != "list" and len(c):
a["P." + c.tag] = tree(c)
return {k: v for k, v in a.items() if k != "Self" and not excluded(tag, k)}
def add_counts(counter, counts):
"""counter.update(counts), with the same result and key order and less
work per key."""
get = counter.get
for k, v in counts.items():
counter[k] = get(k, 0) + v
class Stats:
"""Comparison counts over a set of pairs."""
def __init__(self):
self.docs = 0
# Value coverage: reference values, and those reproduced exactly.
self.values = 0
self.reproduced = 0
# Links whose resource values are left out (RELINKED).
self.relinked = 0
# (tag, key) -> values wrong / missing, and documents affected.
self.gap_wrong = Counter()
self.gap_missing = Counter()
self.gap_docs = Counter()
self.gap_total = Counter()
# Values the output has and the reference does not, by (tag, key),
# and documents affected.
self.extra = 0
self.extra_values = Counter()
self.extra_docs = Counter()
# (pair, reproduced, values) for each pair.
self.pairs = []
self.found = Counter()
self.total = Counter()
# (tag, attribute, "ok" / "wrong" / "missing") -> values; attributes()
# groups them by attribute.
self.attr_ok = Counter()
self.story_ok = Counter()
self.short_docs = Counter() # (kind, tag, attribute) -> documents affected
COUNTERS = ("gap_wrong", "gap_missing", "gap_docs", "gap_total", "extra_values",
"extra_docs", "found", "total", "attr_ok", "story_ok", "short_docs")
def __getstate__(self):
"""Plain dicts in place of the Counters, which unpickle several times
slower."""
state = dict(vars(self))
for c in self.COUNTERS:
state[c] = dict(state[c])
return state
def __setstate__(self, state):
for c in self.COUNTERS:
state[c] = Counter(state[c])
vars(self).update(state)
def attributes(self):
"""(tag, attribute) -> Counter of "ok", "wrong" and "missing"."""
out = defaultdict(Counter)
for (tag, k, kind), n in self.attr_ok.items():
out[(tag, k)][kind] = n
return out
def add(self, other, counts=True):
"""Add the counts of `other`. Without `counts`, only the numbers of
pairs, values and stories (subtotal)."""
self.docs += other.docs
self.values += other.values
self.reproduced += other.reproduced
self.extra += other.extra
self.relinked += other.relinked
self.pairs += other.pairs
add_counts(self.story_ok, other.story_ok)
if not counts:
return
for c in self.COUNTERS:
if c != "story_ok":
add_counts(getattr(self, c), getattr(other, c))
def coverage(self):
return self.reproduced / self.values if self.values else 1.0
def quantile(self, q):
"""Value coverage of the pair at quantile q (0 = lowest)."""
cov = sorted(ok / n if n else 1.0 for _, ok, n in self.pairs)
return cov[min(len(cov) - 1, int(q * len(cov)))] if cov else 1.0
def document_score(self, at_least):
"""Pairs whose value coverage is at least `at_least`."""
return sum(1 for _, ok, n in self.pairs if n == 0 or ok / n >= at_least)
def gaps(self):
"""(tag, key), documents, values wrong, values missing; most
documents first, then most values."""
rows = [(k, d, self.gap_wrong[k], self.gap_missing[k]) for k, d in self.gap_docs.items()]
return sorted(rows, key=lambda r: (-r[1], -(r[2] + r[3]), r[0]))
def extras(self):
"""(tag, key), documents, extra values; most documents first."""
rows = [(k, d, self.extra_values[k]) for k, d in self.extra_docs.items()]
return sorted(rows, key=lambda r: (-r[1], -r[2], r[0]))
def story_line(self):
st = sum(self.story_ok.values())
return (f"{self.story_ok['ok']}/{st} exact, {self.story_ok['wrong']} differ, "
f"{self.story_ok['missing']} missing")
def compare_pair(name, ref, ours, examples, show):
"""Stats of one pair: `ref` and `ours` are load() results. Adds up to
`show` mismatches per attribute to `examples`."""
ref_el, ref_st, ref_rg = ref
our_el, our_st, our_rg = ours
p = Stats()
p.docs = 1
short = set()
for (tag, s), el in ref_el.items():
p.total[tag] += 1
mine = our_el.get((tag, s))
if mine is None:
short.add(("element", tag, ""))
continue
p.found[tag] += 1
mine = props(mine)
for k, v in props(el).items():
if k == "Self":
continue
if k not in mine:
p.attr_ok[(tag, k, "missing")] += 1
short.add(("attribute", tag, k))
elif norm(mine[k]) == norm(v):
p.attr_ok[(tag, k, "ok")] += 1
else:
p.attr_ok[(tag, k, "wrong")] += 1
short.add(("attribute", tag, k))
if len(examples[(tag, k)]) < show:
examples[(tag, k)].append((name, s, v, mine[k]))
for sid, rgs in ref_rg.items():
if our_st.get(sid) != ref_st.get(sid):
continue
mine = our_rg.get(sid, {})
for start, attrs in rgs.items():
p.total["TextRange"] += 1
if start not in mine:
short.add(("element", "TextRange", ""))
continue
p.found["TextRange"] += 1
for k, v in attrs.items():
o = mine[start].get(k)
if o is None:
p.attr_ok[("TextRange", k, "missing")] += 1
short.add(("attribute", "TextRange", k))
elif norm(o) == norm(v):
p.attr_ok[("TextRange", k, "ok")] += 1
else:
p.attr_ok[("TextRange", k, "wrong")] += 1
short.add(("attribute", "TextRange", k))
if len(examples[("TextRange", k)]) < show:
examples[("TextRange", k)].append((name, sid, start, v, o))
for sid, text in ref_st.items():
if sid not in our_st:
p.story_ok["missing"] += 1
short.add(("element", "Story", ""))
elif our_st[sid] == text:
p.story_ok["ok"] += 1
else:
p.story_ok["wrong"] += 1
short.add(("text", "Story", ""))
if len(examples[("Story", "text")]) < max(show, 3):
examples[("Story", "text")].append((name, sid, text[:80], our_st[sid][:80]))
p.short_docs.update(short)
value_coverage(p, ref, ours)
return p
def value_coverage(p, ref, ours):
"""Count reference values and the reproduced ones into `p`: element
presence, attribute and <Properties> values, story text, and the start
and attributes of each text range. Also count the values the output has
and the reference does not (extra values), with the same keys."""
ref_el, ref_st, ref_rg = ref
our_el, our_st, our_rg = ours
total = Counter()
wrong = Counter()
missing = Counter()
extra = Counter()
def compare(tag, theirs, mine):
for k, v in theirs.items():
key = (tag, k)
total[key] += 1
o = mine.get(k)
if o is None:
missing[key] += 1
elif norm(font_name(tag, k, o, mine.get("FontFamily"))) != \
norm(font_name(tag, k, v, theirs.get("FontFamily"))):
wrong[key] += 1
for k in mine.keys() - theirs.keys():
extra[(tag, k)] += 1
for (tag, s), el in ref_el.items():
if excluded(tag, ELEMENT):
continue
theirs = values(tag, el)
mine = our_el.get((tag, s))
if mine is None:
n = 1 + len(theirs)
total[(tag, NO_ELEMENT)] += n
missing[(tag, NO_ELEMENT)] += n
continue
total[(tag, ELEMENT)] += 1
mine = values(tag, mine)
if tag == "Link" and \
mine.get("LinkResourceURI") not in (None, theirs.get("LinkResourceURI")):
theirs = {k: v for k, v in theirs.items() if not RELINKED.fullmatch(k)}
mine = {k: v for k, v in mine.items() if not RELINKED.fullmatch(k)}
p.relinked += 1
compare(tag, theirs, mine)
for (tag, s), el in our_el.items():
if (tag, s) not in ref_el and not excluded(tag, ELEMENT):
extra[(tag, NOT_IN_REFERENCE)] += 1 + len(values(tag, el))
for sid, text in ref_st.items():
key = ("Story", "(text)")
total[key] += 1
if sid not in our_st:
missing[key] += 1
elif our_st[sid] != text:
wrong[key] += 1
for sid in our_st.keys() - ref_st.keys():
extra[("Story", "(text)")] += 1
extra[("TextRange", NOT_IN_REFERENCE)] += sum(1 + len(a) for a in
our_rg.get(sid, {}).values())
for sid, rgs in ref_rg.items():
if our_st.get(sid) != ref_st.get(sid):
n = sum(1 + len(a) for a in rgs.values())
total[("TextRange", NO_TEXT)] += n
missing[("TextRange", NO_TEXT)] += n
continue
mine = our_rg.get(sid, {})
for start, attrs in rgs.items():
if start not in mine:
n = 1 + len(attrs)
total[("TextRange", NO_ELEMENT)] += n
missing[("TextRange", NO_ELEMENT)] += n
continue
total[("TextRange", ELEMENT)] += 1
compare("TextRange", attrs, mine[start])
for start in mine.keys() - rgs.keys():
extra[("TextRange", NOT_IN_REFERENCE)] += 1 + len(mine[start])
n = sum(total.values())
bad = sum(wrong.values()) + sum(missing.values())
p.values += n
p.reproduced += n - bad
p.gap_total.update(total)
p.gap_wrong.update(wrong)
p.gap_missing.update(missing)
p.gap_docs.update(k for k in total if wrong[k] or missing[k])
p.extra += sum(extra.values())
p.extra_values.update(extra)
p.extra_docs.update(k for k, v in extra.items() if v)
def file_sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(1 << 20):
h.update(chunk)
return h.hexdigest()
def cache_dir(binary, root):
"""The folder of a converter binary's outputs under a --cache root:
the first 16 hex digits of the binary's SHA-256."""
return Path(root) / file_sha256(binary)[:16]
def package_entries(path):
"""[name, SHA-256 of the content] of each entry of a package, in order;
None if it is not a readable ZIP file."""
try:
with zipfile.ZipFile(path) as z:
return [[i.filename, hashlib.sha256(z.read(i)).hexdigest()] for i in z.infolist()]
except (OSError, zipfile.BadZipFile):
return None
# Exit statuses of the converter itself (src/main.rs; 101 is a panic). Any
# other status means the process was killed, so the run is not kept.
OWN_EXITS = (0, 1, 2, 101)
def run_killable(cmd, timeout, text):
"""subprocess.run with capture; (exit status or "timeout", stdout, stderr)."""
try:
r = subprocess.run(cmd, capture_output=True, text=text, timeout=timeout)
except subprocess.TimeoutExpired:
return "timeout", "" if text else b"", ""
return r.returncode, r.stdout, r.stderr
def convert_cached(binary, indd, key, folder, pair, timeout=None):
"""Convert `indd` to folder/KEY.idml and return the conversion's record,
or the record of an earlier conversion into the same folder. The record
(folder/KEY.json) has the exit status, stderr, seconds and, for an output, the
package's SHA-256 and entries (package_entries). With `pair`, also runs
`indd xmp` and `indd uids`, whose output goes to folder/KEY.xmp and
folder/KEY.uids (left out when the command fails). A conversion that
times out (exit status "timeout") or is killed by a signal is returned
but not recorded, and neither is the xmp and uids step if one of them is."""
folder = Path(folder)
rec_path, out = folder / f"{key}.json", folder / f"{key}.idml"
tmp = folder / f"{key}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
rec = json.loads(rec_path.read_text())
except (OSError, ValueError):
rec = None
changed = False
if rec is None or rec["exit"] == 0 and not out.exists():
part = tmp.with_suffix(".idml")
t = time.time()
code, _, stderr = run_killable([binary, "convert", indd, part], timeout, True)
rec = {"exit": code, "stderr": stderr, "secs": round(time.time() - t, 2)}
if code == 0:
rec["package"] = file_sha256(part)
rec["entries"] = package_entries(part)
part.replace(out)
else:
part.unlink(missing_ok=True)
if code not in OWN_EXITS:
return rec
changed = True
if pair and "meta" not in rec:
killed = False
for cmd in ("xmp", "uids"):
code, stdout, _ = run_killable([binary, cmd, indd], timeout, False)
if code == 0:
tmp.write_bytes(stdout)
tmp.replace(folder / f"{key}.{cmd}")
killed = killed or code not in OWN_EXITS
if not killed:
rec["meta"] = True
changed = True
if changed:
tmp.write_text(json.dumps(rec))
tmp.replace(rec_path)
return rec
def read_uids(path):
"""UID -> class (None for a slot without a class) from the output of
`indd uids`, or None if there is none."""
try:
text = Path(path).read_text()
except OSError:
return None
uids = {}
for line in text.splitlines():
uid, cls = line.split("\t")
uids[int(uid)] = None if cls == "-" else int(cls, 16)
return uids
def compare_job(rel, name, idml, folder, key, show, save=None):
"""Compare one converted pair (convert_cached); runs in a worker
process. (Stats, stale reasons, examples). With `save`, the result is
also written to that file (comparison cache)."""
folder = Path(folder)
xmp = folder / f"{key}.xmp"
indd_date = modify_date(xmp.read_bytes()) if xmp.exists() else None
uids = read_uids(folder / f"{key}.uids")
ref, ours = load(idml), load(folder / f"{key}.idml")
reasons = stale_reasons(indd_date, idml_modify_date(idml), uids, ref[0], ref[1], ours[1])
examples = defaultdict(list)
p = compare_pair(name, ref, ours, examples, show)
p.pairs = [(rel, p.reproduced, p.values)]
examples = dict(examples)
if save:
# Only built-in types, so that the file loads whatever module name
# this file runs under.
tmp = save.with_name(f"{save.name}.{os.getpid()}.tmp")
tmp.write_bytes(pickle.dumps((p.__getstate__(), reasons, examples),
pickle.HIGHEST_PROTOCOL))
tmp.replace(save)
return p, reasons, examples
@functools.cache
def comparison_code():
"""SHA-256 of what a comparison runs: the source of this file (it
imports no other module of the repository) and the Python version."""
h = hashlib.sha256(Path(__file__).read_bytes())
h.update(sys.version.encode())
return h.hexdigest()
def comparison_key(rel, name, idml, folder, key, rec, show):
"""The name of a pair's result in the comparison cache: a SHA-256 of
everything compare_job reads or is given. The output package is named
by its SHA-256 in the conversion record, so a result is reused by any
converter binary that produces the same output."""
folder = Path(folder)
parts = [comparison_code(), rel, name, str(show), file_sha256(idml),
rec.get("package") or file_sha256(folder / f"{key}.idml")]
for ext in ("xmp", "uids"):
f = folder / f"{key}.{ext}"
parts.append(file_sha256(f) if f.exists() else "-")
return hashlib.sha256("\0".join(parts).encode()).hexdigest()
def cached_comparison(rel, name, idml, folder, key, rec, show, cache, procs):
"""compare_job's result for a pair, from the comparison cache folder
`cache` or computed in `procs` and added to it. (result, taken from
the cache)."""
path = Path(cache) / f"{comparison_key(rel, name, idml, folder, key, rec, show)}.pickle"
try:
state, reasons, examples = pickle.loads(path.read_bytes())
p = Stats.__new__(Stats)
p.__setstate__(state)
return (p, reasons, examples), True
except Exception:
# Not cached, or a damaged file, which compare_job replaces.
pass
return procs.submit(compare_job, rel, name, idml, folder, key, show, path).result(), False
@functools.cache
def schema_selection():
"""The parts tools/validate.sh validates and their schemas, read from
the script: ({part: schema}, {folder: schema of its direct .xml
children})."""
text = (ROOT / "tools" / "validate.sh").read_text()
folders = re.search(r"\bfor d in ([^;]+); do", text)
parts = re.search(r"\bfor p in ((?:[^;\\]|\\\n)+); do", text)
if not folders or not parts or "-name designmap.xml" not in text:
raise SystemExit("tools/validate.sh: part selection not found; "
"update compare.schema_selection")
single = {"designmap.xml": "designmap.rnc"}
single.update({p + ".xml": p + ".rnc" for p in parts[1].replace("\\", " ").split()})
by_folder = {f: f"{f}/{n}.rnc" for f, n in (d.split(":") for d in folders[1].split())}
return single, by_folder
def part_schema(name):
"""The schema tools/validate.sh validates a package entry with, or None."""
single, by_folder = schema_selection()
if name in single:
return single[name]
folder, _, rest = name.partition("/")
if folder in by_folder and rest.endswith(".xml") and "/" not in rest:
return by_folder[folder]
return None
class ValidationCache:
"""Schema errors of package parts (--valid-cache). Jing validates each
part on its own against the schema its path selects, so the errors of a
part depend only on the schema and the part's bytes. The file is a TSV
of a key of the schemas, Jing and tools/validate.sh; the schema; the
part's SHA-256; and its errors as a JSON list, without the part name.
Results under another key are ignored."""
def __init__(self, path, schemas, jing):
h = hashlib.sha256((ROOT / "tools" / "validate.sh").read_bytes())
for d in (Path(schemas), Path(jing)):
for f in sorted(d.rglob("*")):
if f.is_file():
h.update(str(f.relative_to(d)).encode() + b"\0" + f.read_bytes())
self.key = h.hexdigest()[:16]
self.path = Path(path)
self.known = {}
# Parts taken from the cache and validated, in this run.
self.hits = self.validated = 0
# A run killed while appending leaves at most a broken last line,
# which is skipped.
if self.path.exists():
for line in self.path.read_text(errors="replace").splitlines():
try:
key, schema, sha, errs = line.split("\t", 3)
if key == self.key:
self.known[(schema, sha)] = [] if errs == "[]" else json.loads(errs)
except ValueError:
continue
def add(self, results):
"""Record {(schema, part SHA-256): errors}, one write per line."""
new = {k: e for k, e in results.items() if k not in self.known}
self.known.update(new)
if new:
self.path.parent.mkdir(parents=True, exist_ok=True)
with open(self.path, "ab", buffering=0) as f:
if f.tell():
with open(self.path, "rb") as r:
r.seek(-1, os.SEEK_END)
if r.read(1) != b"\n":
f.write(b"\n")
for k, e in new.items():
f.write(f"{self.key}\t{k[0]}\t{k[1]}\t{json.dumps(e)}\n".encode())
def validate_cached(outs, args, cache):
"""Schema errors of packages given as (path, entries or None; see
package_entries), as {path: [messages]} like validate(). With a
ValidationCache, only parts it does not know are validated: they are
copied into reduced packages for one tools/validate.sh run."""
if cache is None:
return validate([p for p, _ in outs], args)
listed = []
todo = {} # (schema, part SHA-256) -> (package, part name)
for path, entries in outs:
if entries is None:
entries = package_entries(path)
parts = [(n, (part_schema(n), s)) for n, s in entries or [] if part_schema(n)]
listed.append((path, parts))
for n, key in parts:
if key in cache.known:
cache.hits += 1
elif key not in todo:
todo[key] = (path, n)
if todo:
by_package = defaultdict(dict)
for key, (path, n) in todo.items():
by_package[path][n] = key
found = {key: [] for key in todo}
with tempfile.TemporaryDirectory() as tmp:
reduced = {}
for i, (path, names) in enumerate(by_package.items()):
r = Path(tmp) / f"{i}.idml"
with zipfile.ZipFile(path) as z, zipfile.ZipFile(r, "w") as w:
for n in names:
w.writestr(n, z.read(n))
reduced[str(r)] = names
for r, msgs in validate(list(reduced), args).items():
for m in msgs:
part, _, rest = m.partition(":")
if part not in reduced[r]:
raise SystemExit(f"tools/validate.sh: unexpected message {m}")
found[reduced[r][part]].append(rest)
cache.add(found)
cache.validated += len(todo)
errs = {}
for path, parts in listed:
msgs = [f"{n}:{m}" for n, key in parts for m in cache.known[key]]
if msgs:
errs[str(path)] = msgs
return errs
def main():
# The run keeps every pair's counts, millions of objects that the cyclic
# garbage collector would scan again and again; it creates no garbage
# cycles worth collecting.
gc.disable()
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--detail", action="append", default=[],
help="print attribute table for this tag (repeatable)")
ap.add_argument("--file", help="only pairs whose path contains this")
ap.add_argument("--show", type=int, default=0, help="show N mismatches per attribute")
ap.add_argument("--schemas", help="IDML RelaxNG schema directory (validate output)")
ap.add_argument("--jing", help="directory with jing.jar, isorelax.jar, saxon.jar")
ap.add_argument("--bin", default=str(BIN), help="converter binary")
ap.add_argument("--all", action="store_true",
help="also convert and validate files without a reference IDML")
ap.add_argument("--exclude", action="append", default=[],
help="leave out files whose path under corpus/ starts with this (repeatable)")
ap.add_argument("--warnings", type=int, default=15,
help="number of warning kinds to list")
ap.add_argument("--shortfalls", type=int, default=0,
help="rank the N biggest shortfalls by documents affected")
ap.add_argument("--trusted", action="store_true",
help="element tables, shortfalls and details over trustworthy pairs only")
ap.add_argument("--stale", type=int, default=0,
help="list N stale pairs with their reasons")
ap.add_argument("--gaps", type=int, default=30,
help="print the N biggest value coverage gaps")
ap.add_argument("--out", default=str(ROOT / "target" / "compare"),
help="directory for pairs.tsv and gaps*.tsv (default target/compare)")
ap.add_argument("--jobs", type=int, default=os.cpu_count() or 1,
help="conversions, comparisons and schema validations run in parallel")
ap.add_argument("--cache", metavar="DIR",
help="keep the conversions in DIR/<binary hash>/ and reuse them")
ap.add_argument("--valid-cache", metavar="FILE",
help="schema validation results by part SHA-256, reused and extended")
ap.add_argument("--subtotal", action="append", default=[], metavar="PREFIX",
help="also report the pairs whose path does not start with PREFIX")
args = ap.parse_args()
check = bool(args.schemas and args.jing)
every = Stats() # all pairs
trusted = Stats() # pairs without a sign of staleness
stale = [] # (name, reasons)
pair_rows = [] # (name, Stats, stale reasons) per pair
examples = defaultdict(list)
failures = []
warnings = Counter()
warned_files = Counter()
seen = set()
known = known_digests()
todo = pairs(args.limit, args.file, args.exclude, seen, known)
others = unpaired(args.file, args.exclude, seen, known) if args.all else []
other_failures = []
# Paths under corpus/ of the paired files that fail, and of those whose
# output is invalid, for --subtotal.
failed_paths = {}
invalid_paths = set()
vcache = ValidationCache(args.valid_cache, args.schemas, args.jing) \
if check and args.valid_cache else None
def count_warnings(stderr):
kinds = set()
for line in stderr.splitlines():
if line.startswith("warning: "):
kind = warning_kind(line[len("warning: "):])
warnings[kind] += 1
kinds.add(kind)
for kind in kinds:
warned_files[kind] += 1
other_invalid = []
invalid = []
# Paired outputs whose only schema errors are InDesign's endnote
# markup: (name, errors, normalised errors, reference IDML, path). They
# pass if the reference has the same errors.
endnote_pending = []
with tempfile.TemporaryDirectory() as tmp:
folder = cache_dir(args.bin, args.cache) if args.cache else Path(tmp)
folder.mkdir(parents=True, exist_ok=True)
# (INDD, reference IDML or None, cache key of the INDD)
items = [(indd, None) for indd in others] + todo
items = [(indd, idml, cache_key(indd, digest(indd, known))) for indd, idml in items]
threads = ThreadPoolExecutor(max(1, args.jobs))
procs = ProcessPoolExecutor(max(1, args.jobs),
mp_context=multiprocessing.get_context("forkserver"))
# A pair is compared in a worker process as soon as it is converted.
# With --cache, a thread first looks for the result in the
# comparison cache.
ccache = Path(args.cache) / "compare" if args.cache else None
if ccache:
ccache.mkdir(parents=True, exist_ok=True)
lookups = ThreadPoolExecutor(max(1, args.jobs))
jobs = {}
reused = 0
lock = threading.Lock()
def comparison(n):
indd, idml, key = items[n]
rel = corpus_path(indd)
with lock:
if n not in jobs and ccache:
jobs[n] = lookups.submit(cached_comparison, rel, indd.name, idml, folder,
key, conversions[n].result(), args.show, ccache,
procs)
elif n not in jobs:
jobs[n] = procs.submit(compare_job, rel, indd.name, idml, folder, key,
args.show)
return jobs[n]
def compare_when_converted(n):
def done(f):
if not f.exception() and f.result()["exit"] == 0:
comparison(n)
return done
# Pairs are converted first, so that their comparisons start early.
order = sorted(range(len(items)), key=lambda n: items[n][1] is None)
conversions = {}
for n in order:
indd, idml, key = items[n]
conversions[n] = threads.submit(convert_cached, args.bin, indd, key, folder,
idml is not None)
if idml is not None:
conversions[n].add_done_callback(compare_when_converted(n))
# Files are taken in batches of VALIDATE_BATCH: one tools/validate.sh
# run per batch. Without --cache, the batch's outputs are deleted
# after it, which bounds the space they take.
for start in range(0, len(items), VALIDATE_BATCH):
batch = range(start, min(start + VALIDATE_BATCH, len(items)))
recs = {n: conversions[n].result() for n in batch}
errs = validate_cached([(folder / f"{items[n][2]}.idml", recs[n]["entries"])
for n in batch if recs[n]["exit"] == 0],
args, vcache) if check else {}
for n in batch:
indd, idml, key = items[n]
rec, out = recs[n], folder / f"{key}.idml"
rel = corpus_path(indd)
count_warnings(rec["stderr"])
if rec["exit"] != 0:
if idml is None:
other_failures.append((indd.name, rec["stderr"].strip()))
else:
failures.append((indd.name, rec["stderr"].strip()))
failed_paths[rel] = rec["stderr"].strip()
continue
e = errs.get(str(out))
if e:
endnotes = endnote_errors(out, e) if idml else None
if endnotes:
endnote_pending.append((indd.name, e, endnotes, idml, rel))
elif idml:
invalid.append((indd.name, e))
invalid_paths.add(rel)
else:
other_invalid.append((indd.name, e))
if idml is not None:
result = comparison(n).result()
if ccache:
result, hit = result
reused += hit
p, reasons, ex = result
for k, found in ex.items():
room = (max(args.show, 3) if k == ("Story", "text") else args.show) \
- len(examples[k])
examples[k] += found[:max(room, 0)]
pair_rows.append((rel, p, reasons))
every.add(p)
if reasons:
stale.append((rel, reasons))
else:
trusted.add(p)
if not args.cache:
out.unlink(missing_ok=True)
threads.shutdown()
if ccache:
lookups.shutdown()
procs.shutdown()
endnote_passed = []
if endnote_pending:
refs = [(ref, None) for _, _, _, ref, _ in endnote_pending]
ref_errs = validate_cached(refs, args, vcache)
for name, e, endnotes, ref, rel in endnote_pending:
theirs = endnote_errors(ref, ref_errs.get(str(ref), [])) or set()
if endnotes <= theirs:
endnote_passed.append(name)
else:
invalid.append((name, e))
invalid_paths.add(rel)
rejected, failures = split_rejected(failures)
other_rejected, other_failures = split_rejected(other_failures)
if rejected:
print(f"rejected files: {len(rejected)} of {len(todo)} paired files "
f"({rejection_kinds(rejected)})")
print(f"conversion failures: {len(failures)} of {len(todo)} paired files")
for name, err in failures[:10]:
print(f" {name}: {err}")
if check:
print(f"schema validation failures: {len(invalid)}")
for name, errs in invalid[:10]:
print(f" {name}: {len(errs)} errors, {errs[:3]}")
print(f"schema errors accepted (endnote and anchored form field markup, as in "
f"the reference): {len(endnote_passed)} files")
if vcache:
print(f"schema validation: {vcache.validated} parts validated, "
f"{vcache.hits} known from {args.valid_cache}")
if args.all:
print(f"files without a reference: {len(others)}, rejected: {len(other_rejected)}"
+ (f" ({rejection_kinds(other_rejected)})" if other_rejected else "")
+ f", conversion failures: {len(other_failures)}")
for name, err in other_failures[:10]:
print(f" {name}: {err}")
if check:
print(f"files without a reference: schema validation failures: {len(other_invalid)}")
with_endnotes = [n for n, errs in other_invalid if any(
'element "Endnote"' in e or "EndnoteRange" in e for e in errs)]
if with_endnotes:
print(f" with endnote markup errors: {len(with_endnotes)}: {with_endnotes[:10]}")
with_forms = [n for n, errs in other_invalid if any(
f'element "{t}" not allowed here' in e for e in errs for t in ANCHORED_FORM_FIELDS)]
if with_forms:
print(f" with anchored form field errors: {len(with_forms)}: {with_forms[:10]}")
for name, errs in other_invalid[:10]:
print(f" {name}: {len(errs)} errors, {errs[:3]}")
if args.cache:
print(f"comparisons: {reused} of {len(pair_rows)} pairs taken from {ccache}")
print(f"warnings: {sum(warnings.values())} (count, files, kind)")
for kind, count in warnings.most_common(args.warnings):
print(f" {count:6} {warned_files[kind]:4} {kind[:150]}")
print(f"story text: {every.story_line()}")
reasons = Counter(r for _, rs in stale for r in rs)
print(f"trustworthy pairs: {trusted.docs} of {every.docs}; stale: {len(stale)} "
f"({', '.join(f'{r}: {n}' for r, n in reasons.most_common())})")
print(f"trustworthy pairs: story text: {trusted.story_line()}")
for name, rs in stale[:args.stale]:
print(f" stale: {name}: {'; '.join(rs)}")
st = trusted if args.trusted else every
which = "trustworthy pairs" if args.trusted else "all pairs"
print(f"\nelements, {which} (produced / in reference):")
for tag, n in st.total.most_common():
print(f" {tag:34} {st.found[tag]:6} / {n:6}")
if args.shortfalls:
print(f"\nshortfalls by documents affected (of {st.docs} {which}):")
attrs = st.attributes()
def instances(kind, tag, k):
"""How many elements, values or stories fall short."""
if kind == "attribute":
return attrs[(tag, k)]["wrong"] + attrs[(tag, k)]["missing"]
if kind == "element":
return st.total[tag] - st.found[tag]
return st.story_ok["wrong"]
ranked = sorted(st.short_docs.items(), key=lambda kv: (-kv[1], -instances(*kv[0]), kv[0]))
for (kind, tag, k), n in ranked[:args.shortfalls]:
what = f"{st.story_ok['wrong']} stories differ"
if kind == "attribute":
c = attrs[(tag, k)]
what = f"{c['wrong']} wrong, {c['missing']} missing of {sum(c.values())}"
elif kind == "element":
what = f"{st.total[tag] - st.found[tag]} of {st.total[tag]} not produced"
print(f" {n:5} {tag + (' ' + k if k else ''):56} {what}")
for tag in args.detail:
print(f"\nattributes of {tag}, {which} (ok / wrong / missing):")
rows = [(k, c) for (t, k), c in st.attributes().items() if t == tag]
for k, c in sorted(rows, key=lambda kc: -sum(kc[1].values())):
print(f" {k:40} {c['ok']:6} {c['wrong']:6} {c['missing']:6}")
for ex in examples[(tag, k)]:
print(f" {ex}")
for ex in examples[("Story", "text")]:
print(" story mismatch:", ex)
headline(args, every, trusted, pair_rows)
subtotals = {prefix: subtotal(prefix, check, todo, pair_rows, failed_paths, invalid_paths)
for prefix in args.subtotal}
write_summary(Path(args.out) / "summary.json", args, check,
(todo, failures, rejected, invalid),
(others, other_failures, other_rejected, other_invalid),
every, trusted, subtotals)
def subtotal(prefix, check, todo, pair_rows, failed_paths, invalid_paths):
"""Print and return the numbers of the pairs whose path under corpus/
does not start with `prefix` (--subtotal)."""
def inside(rel):
return not rel.startswith(prefix)
files = sum(1 for indd, _ in todo if inside(corpus_path(indd)))
every, trusted = Stats(), Stats()
for rel, p, reasons in pair_rows:
if inside(rel):
every.add(p, counts=False)
if not reasons:
trusted.add(p, counts=False)
failed = [(rel, err) for rel, err in failed_paths.items() if inside(rel)]
failures = split_rejected(failed)[1]
invalid = sum(1 for rel in invalid_paths if inside(rel)) if check else None
print(f"\nwithout {prefix}: conversion failures: {len(failures)} of {files} paired files")
if check:
print(f"without {prefix}: schema validation failures: {invalid}")
print(f"without {prefix}: story text: {every.story_line()}")
print(f"without {prefix}: trustworthy pairs: {trusted.docs} of {every.docs}; "
f"story text: {trusted.story_line()}")
print(f"without {prefix}: value coverage: trustworthy pairs {trusted.coverage():.2%} "
f"({trusted.reproduced} of {trusted.values} values), all pairs {every.coverage():.2%}")
return {"files": files, "failures": len(failures), "invalid": invalid,
"trustworthy": pair_numbers(trusted), "all_pairs": pair_numbers(every)}
def pair_numbers(st):
"""The numbers of a set of pairs in summary.json."""
return {"pairs": st.docs, "values": st.values, "reproduced": st.reproduced,
"stories": {"exact": st.story_ok["ok"], "differ": st.story_ok["wrong"],
"missing": st.story_ok["missing"]}}
def write_summary(path, args, check, paired, unpaired_files, every, trusted, subtotals):
"""Write the numbers of the README's "Current numbers" section, and the
options they were measured with, as JSON (tools/readme_numbers.py).
`paired` and `unpaired_files` are (files, failures, rejected,
invalid): conversion failures of valid files, and files rejected for a
correct reason (REJECTIONS)."""
def files(group, converted):
todo, failures, rejected, invalid = group
if not converted:
return None
return {"files": len(todo),
"failures": [error_message(err) for _, err in failures],
"rejected": [error_message(err) for _, err in rejected],
"invalid": len(invalid) if check else None}
summary = {
"options": {"all": args.all, "validated": check, "exclude": args.exclude,
"limit": args.limit, "file": args.file},
"paired": files(paired, True),
"others": files(unpaired_files, args.all),
"trustworthy": pair_numbers(trusted),
"all_pairs": pair_numbers(every),
}
if subtotals:
summary["subtotals"] = subtotals
path.write_text(json.dumps(summary, indent=1) + "\n")
def gap_name(key):
tag, k = key
return f"{tag} {k}"
def write_gaps(path, st):
with open(path, "w") as f:
f.write("tag\tkey\tdocuments\twrong\tmissing\tvalues\n")
for key, docs, w, m in st.gaps():
f.write(f"{key[0]}\t{key[1]}\t{docs}\t{w}\t{m}\t{st.gap_total[key]}\n")
def write_extras(path, st):
with open(path, "w") as f:
f.write("tag\tkey\tdocuments\textra\n")
for key, docs, n in st.extras():
f.write(f"{key[0]}\t{key[1]}\t{docs}\t{n}\n")
def write_values(path, st):
"""Every key with its values and how many are reproduced, for checking
that a change reproduces no fewer values of any key."""
with open(path, "w") as f:
f.write("tag\tkey\tvalues\treproduced\twrong\tmissing\n")
for key in sorted(st.gap_total):
w, m = st.gap_wrong[key], st.gap_missing[key]
f.write(f"{key[0]}\t{key[1]}\t{st.gap_total[key]}\t{st.gap_total[key] - w - m}"
f"\t{w}\t{m}\n")
def headline(args, every, trusted, pair_rows):
"""Print value coverage, document scores and the biggest gaps; write
pairs.tsv, gaps.tsv (trustworthy pairs) and gaps-all.tsv to --out."""
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
with open(out / "pairs.tsv", "w") as f:
f.write("path\ttrusted\tcoverage\treproduced\tvalues\textra\tstories"
"\tstories_identical\tstale reasons\n")
for name, p, reasons in sorted(pair_rows, key=lambda r: r[1].coverage()):
f.write(f"{name}\t{not reasons}\t{p.coverage():.5f}\t{p.reproduced}\t{p.values}"
f"\t{p.extra}\t{sum(p.story_ok.values())}\t{p.story_ok['ok']}"
f"\t{'; '.join(reasons)}\n")
write_gaps(out / "gaps.tsv", trusted)
write_gaps(out / "gaps-all.tsv", every)
write_extras(out / "extras.tsv", trusted)
write_extras(out / "extras-all.tsv", every)
write_values(out / "values.tsv", trusted)
write_values(out / "values-all.tsv", every)
print("\nheadline (docs/measurement.md):")
for label, st in (("trustworthy pairs", trusted), ("all pairs", every)):
n = st.docs or 1
print(f" {label}: value coverage {st.coverage():.2%} "
f"({st.reproduced} of {st.values} values); documents at >= 99 %: "
f"{st.document_score(0.99)}/{st.docs} ({st.document_score(0.99) / n:.1%}), "
f">= 99.9 %: {st.document_score(0.999)}/{st.docs} "
f"({st.document_score(0.999) / n:.1%})")
print(f" extra values (in the output, not in the reference): {st.extra} "
f"({st.extra / (st.values or 1):.2%} of reference values); "
f"links resolved to another file at export: {st.relinked}")
print(f" per pair: lowest {st.quantile(0):.1%}, 10th percentile {st.quantile(0.1):.1%}, "
f"median {st.quantile(0.5):.1%}, 90th percentile {st.quantile(0.9):.1%}, "
f"highest {st.quantile(1):.1%}")
rows = trusted.gaps()
print(f"\nvalue gaps, trustworthy pairs, by documents affected "
f"(documents, values wrong, missing, of values; all in {out / 'gaps.tsv'}):")
for key, docs, w, m in rows[:args.gaps]:
print(f" {docs:5} {w:7} {m:7} {trusted.gap_total[key]:8} {gap_name(key)[:90]}")
print("\nvalue gaps, trustworthy pairs, by values "
"(documents, values wrong, missing, of values):")
for key, docs, w, m in sorted(rows, key=lambda r: -(r[2] + r[3]))[:args.gaps // 2]:
print(f" {docs:5} {w:7} {m:7} {trusted.gap_total[key]:8} {gap_name(key)[:90]}")
rows = trusted.extras()
print(f"\nextra values, trustworthy pairs, by documents affected "
f"(documents, extra values; all in {out / 'extras.tsv'}):")
for key, docs, n in rows[:args.gaps // 2]:
print(f" {docs:5} {n:7} {gap_name(key)[:90]}")
if __name__ == "__main__":
main()