"""Canonical JSON, RFC 8785 (JSON Canonicalization Scheme). Non-normative. The normative rule is spec/01-conventions.md. Most values serialize through the standard library's C encoder, which escapes strings exactly as RFC 8785 does. Values it would write differently take the exact path below: floats, integers beyond 2^53, and member names outside the Basic Multilingual Plane, which RFC 8785 sorts by UTF-16 code units rather than by code points. """ import json import math _C = json.JSONEncoder(ensure_ascii=False, separators=(",", ":"), sort_keys=True, allow_nan=False) _EXACT_INT = 2**53 _ESC = { 0x08: "\\b", 0x09: "\\t", 0x0A: "\\n", 0x0C: "\\f", 0x0D: "\\r", 0x22: '\\"', 0x5C: "\\\\", } def _string(s): out = ['"'] for ch in s: cp = ord(ch) if 0xD800 <= cp <= 0xDFFF: raise ValueError("lone surrogate in string") if cp in _ESC: out.append(_ESC[cp]) elif cp < 0x20: out.append("\\u%04x" % cp) else: out.append(ch) out.append('"') return "".join(out) def _number(x): """ECMAScript Number.prototype.toString, as RFC 8785 requires.""" if isinstance(x, bool): raise TypeError("bool is not a number") if isinstance(x, int): if abs(x) > 2**53: raise ValueError("integer outside the IEEE 754 exact range") return str(x) if not math.isfinite(x): raise ValueError("NaN and Infinity are not JSON") if x == 0: return "0" if x < 0: return "-" + _number(-x) # Shortest round-trip digits, as ECMAScript requires. r = repr(x) if "e" in r or "E" in r: mant, exp = r.lower().split("e") exp = int(exp) else: mant, exp = r, 0 if "." in mant: ip, fp = mant.split(".") else: ip, fp = mant, "" digits = (ip + fp).lstrip("0") # Position of the decimal point relative to the start of `digits`. lead_zeros = len(ip + fp) - len((ip + fp).lstrip("0")) n = len(ip) - lead_zeros + exp digits = digits.rstrip("0") or "0" k = len(digits) if k <= n <= 21: return digits + "0" * (n - k) if 0 < n <= 21: return digits[:n] + "." + digits[n:] if -6 < n <= 0: return "0." + "0" * (-n) + digits e = n - 1 sign = "+" if e >= 0 else "-" if k == 1: return digits + "e" + sign + str(abs(e)) return digits[0] + "." + digits[1:] + "e" + sign + str(abs(e)) def _key(s): # RFC 8785 sorts member names by their UTF-16 code units. return s.encode("utf-16-be") def _fast(v): """True when the C encoder writes `v` exactly as RFC 8785 does.""" t = type(v) if t is str or v is None or t is bool: return True if t is int: return -_EXACT_INT <= v <= _EXACT_INT if t is dict: for k, x in v.items(): if type(k) is not str or (not k.isascii() and max(k) > "\uffff") or not _fast(x): return False return True if t is list or t is tuple: return all(_fast(x) for x in v) return False def dumps(value): """Serialize `value` to canonical JSON text.""" if _fast(value): text = _C.encode(value) if not text.isascii(): try: text.encode("utf-8") except UnicodeEncodeError: raise ValueError("lone surrogate in string") from None return text return _exact(value) def _exact(value): if value is None: return "null" if value is True: return "true" if value is False: return "false" if isinstance(value, str): return _string(value) if isinstance(value, (int, float)): return _number(value) if isinstance(value, (list, tuple)): return "[" + ",".join(dumps(v) for v in value) + "]" if isinstance(value, dict): for k in value: if not isinstance(k, str): raise TypeError("object keys must be strings") items = sorted(value.items(), key=lambda kv: _key(kv[0])) return "{" + ",".join(_string(k) + ":" + dumps(v) for k, v in items) + "}" raise TypeError("not JSON: %r" % type(value)) def dumpb(value): """Canonical JSON as UTF-8 bytes.""" return dumps(value).encode("utf-8") def loads_line(data): """Parse one canonical JSON line of bytes, refusing a line that is not canonical.""" import json as _json value = _json.loads(data.decode("utf-8")) if dumpb(value) != data: raise ValueError("not canonical JSON") return value