#!/usr/bin/env python3 """pairwise_contrast.py: the contrast between colors that sit side by side, not only text on its own fill. Describe each theme as JSON: its states, and the fill and text color of each. For every theme it prints the text contrast of each state, the WCAG contrast of every pair of fills, and the smallest CIEDE2000 difference between pairs under simulated deuteranopia and protanopia (Viénot, Brettel and Mollon, 1999). Pairs under 3:1 are flagged. python3 pairwise_contrast.py --example > palette.json # a sample input file python3 pairwise_contrast.py palette.json # the report python3 pairwise_contrast.py palette.json --json # the same figures as JSON python3 pairwise_contrast.py --self-test # known values; exits 1 on a mismatch Standard library only. It reads the file you name and prints to the terminal. It does not test real eyes, size, motion or layout. The simulated difference has no standard floor: use it to rank pairs. """ import argparse import itertools import json import math import re import sys UNDER = 3.0 # WCAG 2.1 SC 1.4.11 and Technique G209: 3:1 against the colors a shape touches VISIONS = ("deuteranopia", "protanopia") # ---- color math ------------------------------------------------------------------------------ def rgb(hexcolor): return tuple(int(hexcolor[i:i + 2], 16) for i in (1, 3, 5)) def linear(c): """One sRGB channel, 0-255, to linear light.""" c /= 255 return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 def encode(v): """Linear light back to one sRGB channel, 0-255.""" v = max(0.0, min(1.0, v)) return 255 * (12.92 * v if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055) def luminance(hexcolor): r, g, b = [linear(x) for x in rgb(hexcolor)] return 0.2126 * r + 0.7152 * g + 0.0722 * b def contrast(a, b): """WCAG 2.x contrast ratio between two colors, 1 to 21.""" la, lb = luminance(a), luminance(b) if la < lb: la, lb = lb, la return (la + 0.05) / (lb + 0.05) # Viénot, Brettel and Mollon (1999): linear RGB to LMS cone space, replace the missing cone, back to RGB. LMS = [[17.8824, 43.5161, 4.11935], [3.45565, 27.1554, 3.86714], [0.0299566, 0.184309, 1.46709]] def inverse3(m): a, b, c = m[0] d, e, f = m[1] g, h, i = m[2] det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g) return [[(e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det], [(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det], [(d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det]] LMS_INVERSE = inverse3(LMS) def multiply(m, v): return [sum(m[r][c] * v[c] for c in range(3)) for r in range(3)] def simulate(hexcolor, vision): """The color as a deuteranope or protanope would see it, rounded to an 8-bit hex color.""" l, m, s = multiply(LMS, [linear(x) for x in rgb(hexcolor)]) if vision == "protanopia": l = 2.02344 * m - 2.52581 * s elif vision == "deuteranopia": m = 0.494207 * l + 1.24827 * s else: raise ValueError("vision must be one of %s" % ", ".join(VISIONS)) return "#%02X%02X%02X" % tuple(round(encode(x)) for x in multiply(LMS_INVERSE, [l, m, s])) def to_lab(hexcolor): """sRGB (D65) to CIELAB.""" r, g, b = [linear(x) for x in rgb(hexcolor)] x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b xn, yn, zn = 0.95047, 1.0, 1.08883 def f(t): return t ** (1 / 3) if t > 216 / 24389 else (24389 / 27 * t + 16) / 116 fx, fy, fz = f(x / xn), f(y / yn), f(z / zn) return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)) def ciede2000(lab1, lab2): """CIEDE2000 between two (L, a, b) colors, following Sharma, Wu and Dalal (2005).""" L1, a1, b1 = lab1 L2, a2, b2 = lab2 C1, C2 = math.hypot(a1, b1), math.hypot(a2, b2) Cb = (C1 + C2) / 2 G = 0.5 * (1 - math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7))) a1p, a2p = (1 + G) * a1, (1 + G) * a2 C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2) h1p = math.degrees(math.atan2(b1, a1p)) % 360 h2p = math.degrees(math.atan2(b2, a2p)) % 360 dLp, dCp = L2 - L1, C2p - C1p if C1p * C2p == 0: dhp = 0 else: dhp = h2p - h1p if dhp > 180: dhp -= 360 elif dhp < -180: dhp += 360 dHp = 2 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp / 2)) Lbp, Cbp = (L1 + L2) / 2, (C1p + C2p) / 2 if C1p * C2p == 0: hbp = h1p + h2p elif abs(h1p - h2p) <= 180: hbp = (h1p + h2p) / 2 elif h1p + h2p < 360: hbp = (h1p + h2p + 360) / 2 else: hbp = (h1p + h2p - 360) / 2 T = (1 - 0.17 * math.cos(math.radians(hbp - 30)) + 0.24 * math.cos(math.radians(2 * hbp)) + 0.32 * math.cos(math.radians(3 * hbp + 6)) - 0.20 * math.cos(math.radians(4 * hbp - 63))) dth = 30 * math.exp(-(((hbp - 275) / 25) ** 2)) Rc = 2 * math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7)) Sl = 1 + 0.015 * (Lbp - 50) ** 2 / math.sqrt(20 + (Lbp - 50) ** 2) Sc, Sh = 1 + 0.045 * Cbp, 1 + 0.015 * Cbp * T Rt = -math.sin(math.radians(2 * dth)) * Rc total = (dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2 + Rt * (dCp / Sc) * (dHp / Sh) return math.sqrt(max(0.0, total)) def difference(a, b, vision=None): """CIEDE2000 between two hex colors, as seen with normal vision or through a simulation.""" if vision: a, b = simulate(a, vision), simulate(b, vision) return ciede2000(to_lab(a), to_lab(b)) # ---- reading the input ----------------------------------------------------------------------- class InputError(ValueError): """The input is not what the tool expects; the message says where.""" def no_duplicate_keys(pairs): seen = {} for key, value in pairs: if key in seen: raise InputError("the key %r appears twice in one object" % key) seen[key] = value return seen def check_keys(obj, allowed, where): if not isinstance(obj, dict): raise InputError("%s: expected an object" % where) for key in obj: if key not in allowed: raise InputError("%s: unknown key %r (expected %s)" % (where, key, ", ".join(sorted(allowed)))) def parse_hex(value, where): match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", value) if isinstance(value, str) else None if not match: raise InputError("%s: expected a hex color such as #1D3557, got %r" % (where, value)) digits = match.group(1) if len(digits) == 3: digits = "".join(ch * 2 for ch in digits) return "#" + digits.upper() MAX_LABEL = 60 def parse_label(value, where): """Theme and state names are printed to the terminal: plain text only, no control characters.""" if not isinstance(value, str) or not value.strip(): raise InputError("%s: expected a non-empty string" % where) if any(ord(ch) < 32 or 127 <= ord(ch) < 160 for ch in value): raise InputError("%s: control characters are not allowed" % where) if len(value) > MAX_LABEL: raise InputError("%s: longer than %d characters" % (where, MAX_LABEL)) return value def read_themes(data): """Check the parsed JSON and return the themes with every color as #RRGGBB.""" check_keys(data, {"themes", "note"}, "input") themes = data.get("themes") if not isinstance(themes, list) or not themes: raise InputError('input: "themes" must be a list with at least one theme') out = [] for i, theme in enumerate(themes): where = "themes[%d]" % i check_keys(theme, {"name", "note", "canvas", "empty", "states"}, where) name = parse_label(theme.get("name", "theme %d" % (i + 1)), where + ".name") states = theme.get("states") if not isinstance(states, dict) or len(states) < 2: raise InputError('%s: "states" must be an object with at least two states' % where) parsed = [] for state, colors in states.items(): state = parse_label(state, "%s.states key" % where) here = "%s.states.%s" % (where, state) check_keys(colors, {"fill", "text"}, here) for key in ("fill", "text"): if key not in colors: raise InputError('%s: missing "%s"' % (here, key)) parsed.append({"name": state, "fill": parse_hex(colors["fill"], here + ".fill"), "text": parse_hex(colors["text"], here + ".text")}) item = {"name": name, "states": parsed} for key in ("canvas", "empty"): if key in theme: item[key] = parse_hex(theme[key], "%s.%s" % (where, key)) out.append(item) return out # ---- the measurements ------------------------------------------------------------------------ def analyze(themes): """Measure every theme. Numbers are unrounded; flags compare the unrounded ratio with UNDER.""" report = [] for theme in themes: states = [dict(s, text_contrast=contrast(s["fill"], s["text"])) for s in theme["states"]] pairs = [] for a, b in itertools.combinations(states, 2): ratio = contrast(a["fill"], b["fill"]) diffs = {"normal": difference(a["fill"], b["fill"])} for vision in VISIONS: diffs[vision] = difference(a["fill"], b["fill"], vision) pairs.append({"a": a["name"], "b": b["name"], "contrast": ratio, "under": ratio < UNDER, "difference": diffs}) smallest = min(({"value": p["difference"][v], "a": p["a"], "b": p["b"], "vision": v} for p in pairs for v in VISIONS), key=lambda row: row["value"]) against = {} for key in ("canvas", "empty"): if key in theme: rows = [{"state": s["name"], "contrast": contrast(s["fill"], theme[key])} for s in states] for row in rows: row["under"] = row["contrast"] < UNDER against[key] = {"color": theme[key], "rows": rows} report.append({"name": theme["name"], "states": states, "pairs": pairs, "smallest": smallest, "against": against}) return report # ---- output ---------------------------------------------------------------------------------- def ratio_text(x): return "%.2f" % x def diff_text(x): return "%.1f" % x def cell(x): return ratio_text(x) + ":1" def render_text(report): mark = "under %g:1" % UNDER out = [] for theme in report: out += [theme["name"], "", " text on its own fill"] width = max(len(s["name"]) for s in theme["states"]) for s in theme["states"]: out.append(" %-*s %s on %s %8s" % (width, s["name"], s["text"], s["fill"], cell(s["text_contrast"]))) labels = ["%s / %s" % (p["a"], p["b"]) for p in theme["pairs"]] width = max(len("pair"), max(len(label) for label in labels)) out += ["", " pairs of fills; the last three columns are CIEDE2000 differences", " %-*s %8s %6s %6s %6s" % (width, "pair", "contrast", "normal", "deut", "prot")] for label, p in zip(labels, theme["pairs"]): d = p["difference"] line = " %-*s %8s %6s %6s %6s" % (width, label, cell(p["contrast"]), diff_text(d["normal"]), diff_text(d["deuteranopia"]), diff_text(d["protanopia"])) out.append(line + (" " + mark if p["under"] else "")) for key, ref in theme["against"].items(): out += ["", " fills against %s %s" % (key, ref["color"])] width = max(len(r["state"]) for r in ref["rows"]) for r in ref["rows"]: out.append(" %-*s %8s%s" % (width, r["state"], cell(r["contrast"]), " " + mark if r["under"] else "")) low = theme["smallest"] flagged = ["%s / %s" % (p["a"], p["b"]) for p in theme["pairs"] if p["under"]] out += ["", " smallest simulated difference: %s (%s / %s, %s)" % (diff_text(low["value"]), low["a"], low["b"], low["vision"]), " pairs %s: %s" % (mark, ", ".join(flagged) if flagged else "none"), ""] out.append("CIEDE2000 differences are shown for normal vision and as simulated: deut = deuteranopia, prot = protanopia.") return "\n".join(out) def as_json(report): themes = [] for t in report: item = { "name": t["name"], "states": [{"name": s["name"], "fill": s["fill"], "text": s["text"], "text_contrast": round(s["text_contrast"], 2)} for s in t["states"]], "pairs": [{"a": p["a"], "b": p["b"], "contrast": round(p["contrast"], 2), "flagged": p["under"], "ciede2000": {k: round(v, 1) for k, v in p["difference"].items()}} for p in t["pairs"]], "smallest_simulated_difference": {"value": round(t["smallest"]["value"], 1), "pair": [t["smallest"]["a"], t["smallest"]["b"]], "vision": t["smallest"]["vision"]}, } for key, ref in t["against"].items(): item[key] = {"color": ref["color"], "contrast": {r["state"]: round(r["contrast"], 2) for r in ref["rows"]}, "flagged": [r["state"] for r in ref["rows"] if r["under"]]} themes.append(item) return {"threshold": UNDER, "themes": themes} # ---- sample input ---------------------------------------------------------------------------- EXAMPLE = """\ { "note": "Yapword tile palettes: the six rows of the table in the essay Sibling contrast. A state is a tile fill and the color of the letter on it.", "themes": [ { "name": "green, amber, warm grey", "note": "shipped 05-23", "states": { "correct": {"fill": "#3FA34D", "text": "#FFFFFF"}, "present": {"fill": "#E0A82E", "text": "#FFFFFF"}, "absent": {"fill": "#B8AE9C", "text": "#FFFFFF"} } }, { "name": "navy, gold, slate #5B6679", "note": "shipped 06-04", "states": { "correct": {"fill": "#1B2A4A", "text": "#FFFFFF"}, "present": {"fill": "#E8B84B", "text": "#1A1714"}, "absent": {"fill": "#5B6679", "text": "#FFFFFF"} } }, { "name": "navy, gold, slate #6A768A", "note": "shipped 06-07", "states": { "correct": {"fill": "#1B2A4A", "text": "#FFFFFF"}, "present": {"fill": "#E8B84B", "text": "#1A1714"}, "absent": {"fill": "#6A768A", "text": "#FFFFFF"} } }, { "name": "navy, gold, slate #656E81", "note": "shipped 06-08", "states": { "correct": {"fill": "#1B2A4A", "text": "#FFFFFF"}, "present": {"fill": "#E8B84B", "text": "#1A1714"}, "absent": {"fill": "#656E81", "text": "#FFFFFF"} } }, { "name": "final, light theme", "note": "shipped 06-08. canvas and empty are optional: canvas is the page behind the board, empty is a typed tile not yet submitted.", "canvas": "#FBF6EC", "empty": "#FFFFFF", "states": { "correct": {"fill": "#1D3557", "text": "#FFFFFF"}, "present": {"fill": "#FFB703", "text": "#000000"}, "absent": {"fill": "#D3D6DA", "text": "#000000"} } }, { "name": "final, dark theme", "note": "shipped 06-10", "canvas": "#1C1C1E", "empty": "#1C1C1E", "states": { "correct": {"fill": "#457B9D", "text": "#FFFFFF"}, "present": {"fill": "#E9C46A", "text": "#000000"}, "absent": {"fill": "#3A3A3C", "text": "#EBEBF5"} } } ] } """ # ---- self-test ------------------------------------------------------------------------------- # What the essay prints for the six themes in EXAMPLE, in its column order: # correct/absent, correct/present, present/absent, smallest simulated difference. ESSAY_TABLE = { "green, amber, warm grey": ("1.46", "1.50", "1.02", "8.1"), "navy, gold, slate #5B6679": ("2.45", "7.71", "3.14", "20.8"), "navy, gold, slate #6A768A": ("3.10", "7.71", "2.49", "26.5"), "navy, gold, slate #656E81": ("2.78", "7.71", "2.78", "23.6"), "final, light theme": ("8.48", "7.08", "1.20", "32.1"), "final, dark theme": ("2.47", "2.75", "6.79", "26.2"), } ESSAY_TEXT = [ # theme, state, contrast of the state's text on its fill ("navy, gold, slate #5B6679", "correct", "14.22"), ("navy, gold, slate #5B6679", "absent", "5.80"), ("navy, gold, slate #6A768A", "absent", "4.59"), ("final, light theme", "correct", "12.36"), ] ESSAY_EMPTY = [ # theme, the absent fill against the empty tile ("final, light theme", "1.46"), ("final, dark theme", "1.50"), ] # Simulated colors recorded in a separate earlier report that used the same published matrices. RECORDED = [ ("#326FE8", "deuteranopia", "#6262E8"), ("#326FE8", "protanopia", "#6A6AE8"), ("#E9C46A", "deuteranopia", "#D0D068"), ("#E9C46A", "protanopia", "#C9C96A"), ] # Sharma, Wu and Dalal (2005), supplementary test data: Lab1, Lab2, CIEDE2000. SHARMA = [ ((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425), ((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615), ((50.0, 2.8361, -74.0200), (50.0, 0.0, -82.7485), 3.4412), ((50.0, -1.3802, -84.2814), (50.0, 0.0, -82.7485), 1.0000), ((50.0, -1.1848, -84.8006), (50.0, 0.0, -82.7485), 1.0000), ((50.0, -0.9009, -85.5211), (50.0, 0.0, -82.7485), 1.0000), ((50.0, 0.0, 0.0), (50.0, -1.0, 2.0), 2.3669), ((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669), ((50.0, 2.49, -0.001), (50.0, -2.49, 0.0009), 7.1792), ((50.0, 2.49, -0.001), (50.0, -2.49, 0.0010), 7.1792), ((50.0, 2.49, -0.001), (50.0, -2.49, 0.0011), 7.2195), ((50.0, 2.49, -0.001), (50.0, -2.49, 0.0012), 7.2195), ((50.0, -0.001, 2.49), (50.0, 0.0009, -2.49), 4.8045), ((50.0, -0.001, 2.49), (50.0, 0.0010, -2.49), 4.8045), ((50.0, -0.001, 2.49), (50.0, 0.0011, -2.49), 4.7461), ((50.0, 2.5, 0.0), (50.0, 0.0, -2.5), 4.3065), ((50.0, 2.5, 0.0), (73.0, 25.0, -18.0), 27.1492), ((50.0, 2.5, 0.0), (61.0, -5.0, 29.0), 22.8977), ((50.0, 2.5, 0.0), (56.0, -27.0, -3.0), 31.9030), ((50.0, 2.5, 0.0), (58.0, 24.0, 15.0), 19.4535), ((50.0, 2.5, 0.0), (50.0, 3.1736, 0.5854), 1.0000), ((50.0, 2.5, 0.0), (50.0, 3.2972, 0.0), 1.0000), ((50.0, 2.5, 0.0), (50.0, 1.8634, 0.5757), 1.0000), ((50.0, 2.5, 0.0), (50.0, 3.2592, 0.3350), 1.0000), ((60.2574, -34.0099, 36.2677), (60.4626, -34.1751, 39.4387), 1.2644), ((63.0109, -31.0961, -5.8663), (62.8187, -29.7946, -4.0864), 1.2630), ((61.2901, 3.7196, -5.3901), (61.4292, 2.2480, -4.9620), 1.8731), ((35.0831, -44.1164, 3.7933), (35.0232, -40.0716, 1.5901), 1.8645), ((22.7233, 20.0904, -46.6940), (23.0331, 14.9730, -42.5619), 2.0373), ((36.4612, 47.8580, 18.3852), (36.2715, 50.5065, 21.2231), 1.4146), ((90.8027, -2.0831, 1.4410), (91.1528, -1.6435, 0.0447), 1.4441), ((90.9257, -0.5406, -0.9208), (88.6381, -0.8985, -0.7239), 1.5381), ((6.7747, -0.2908, -2.4247), (5.8714, -0.0985, -2.2286), 0.6377), ((2.0776, 0.0795, -1.1350), (0.9033, -0.0636, -0.5514), 0.9082), ] def self_test(): """Check known values. Returns 0 if every one matches, 1 otherwise.""" checks = failures = 0 def group(title, rows): # rows are (label, got, expected); one line per group, then any mismatches nonlocal checks, failures bad = [row for row in rows if row[1] != row[2]] checks += len(rows) failures += len(bad) print(("FAIL" if bad else "ok ") + " " + title) for label, got, want in bad: print(" %s: got %s, expected %s" % (label, got, want)) group("contrast: black on white 21.00, a color on itself 1.00, #767676 and #777777 on white 4.54 and 4.48", [ ("black on white", ratio_text(contrast("#000000", "#FFFFFF")), "21.00"), ("a color on itself", ratio_text(contrast("#1D3557", "#1D3557")), "1.00"), ("#767676 on white", ratio_text(contrast("#767676", "#FFFFFF")), "4.54"), ("#777777 on white", ratio_text(contrast("#777777", "#FFFFFF")), "4.48"), ]) group("CIEDE2000: %d test pairs from Sharma, Wu and Dalal (2005), to 4 decimals" % len(SHARMA), [ ("pair %d" % n, "%.4f" % ciede2000(a, b), "%.4f" % want) for n, (a, b, want) in enumerate(SHARMA, 1)]) greys = [(g, v) for g in ("#000000", "#808080", "#FFFFFF") for v in VISIONS] group("simulation: %d recorded colors, and %d neutral greys left unchanged" % (len(RECORDED), len(greys)), [("%s %s" % (c, v), simulate(c, v), want) for c, v, want in RECORDED] + [("%s %s" % (c, v), simulate(c, v), c) for c, v in greys]) report = analyze(read_themes(json.loads(EXAMPLE, object_pairs_hook=no_duplicate_keys))) by_name = {t["name"]: t for t in report} group("the example holds the essay's six themes", [("themes", sorted(by_name), sorted(ESSAY_TABLE))]) print(" essay table: correct/absent, correct/present, present/absent, smallest simulated difference") flagged_total = pair_total = 0 width = max(len(name) for name in ESSAY_TABLE) for name, want in ESSAY_TABLE.items(): if name not in by_name: continue theme = by_name[name] pairs = {frozenset((p["a"], p["b"])): p for p in theme["pairs"]} got = (ratio_text(pairs[frozenset(("correct", "absent"))]["contrast"]), ratio_text(pairs[frozenset(("correct", "present"))]["contrast"]), ratio_text(pairs[frozenset(("present", "absent"))]["contrast"]), diff_text(theme["smallest"]["value"])) names = ("correct/absent", "correct/present", "present/absent", "smallest simulated difference") rows = list(zip(["%s, %s" % (name, n) for n in names], got, want)) # a pair is flagged exactly when the figure the essay prints is under 3:1 pair_keys = (("correct", "absent"), ("correct", "present"), ("present", "absent")) for (x, y), printed in zip(pair_keys, want[:3]): p = pairs[frozenset((x, y))] rows.append(("%s, %s/%s flagged" % (name, x, y), p["under"], float(printed) < UNDER)) flagged_total += p["under"] pair_total += 1 group("%-*s %5s %5s %5s %5s" % (width, name, *got), rows) group("text on fill: " + " ".join(v for _, _, v in ESSAY_TEXT), [ ("%s, %s" % (theme, state), ratio_text(next(s["text_contrast"] for s in by_name[theme]["states"] if s["name"] == state)), want) for theme, state, want in ESSAY_TEXT]) group("absent against the empty tile: " + " and ".join(v for _, v in ESSAY_EMPTY), [ (theme, ratio_text(next(r["contrast"] for r in by_name[theme]["against"]["empty"]["rows"] if r["state"] == "absent")), want) for theme, want in ESSAY_EMPTY]) group("a pair quoted in the essay, #0033CC against #1C1C1E: 1.90", [("#0033CC / #1C1C1E", ratio_text(contrast("#0033CC", "#1C1C1E")), "1.90")]) print(" flagged under %g:1 across the six themes: %d of %d pairs" % (UNDER, flagged_total, pair_total)) print("%d of %d checks passed" % (checks - failures, checks)) return 1 if failures else 0 # ---- command line ---------------------------------------------------------------------------- def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("file", nargs="?", help="JSON file describing the themes (see --example)") ap.add_argument("--json", action="store_true", help="print the figures as JSON instead of a report") ap.add_argument("--example", action="store_true", help="print a sample input file and exit") ap.add_argument("--self-test", action="store_true", help="check known values; exit 1 on a mismatch") args = ap.parse_args(argv) if args.example: sys.stdout.write(EXAMPLE) return 0 if args.self_test: return self_test() if not args.file: ap.error("give a JSON file, or use --example or --self-test") try: with open(args.file, encoding="utf-8") as fh: themes = read_themes(json.load(fh, object_pairs_hook=no_duplicate_keys)) except (OSError, ValueError) as err: print("error: %s: %s" % (args.file, err), file=sys.stderr) return 2 report = analyze(themes) print(json.dumps(as_json(report), indent=2) if args.json else render_text(report)) return 0 if __name__ == "__main__": sys.exit(main())