refactor: move the reference implementation to src/paperspec
The oracle had two import names, `tools.oracle` for the tools and `paperspec` for other repositories, which could load the same code twice in one process and needed a gate keyed on the module name. It now lives in the standard src layout under its one name. - git mv tools/oracle -> src/paperspec; the 22 rule modules are unchanged. - The tools import `paperspec`. `tools/__init__.py` now holds the Python 3.14 and cryptography/jsonschema check and puts src/ first on sys.path, so the tools run this checkout's code with or without pixi. The package's own __init__ checks nothing and exposes __version__. - pyproject.toml: version from git tags via setuptools-scm (guess-next-dev, node-and-timestamp, src/paperspec/_version.py, gitignored). The packages stay listed rather than found because schemas/, sql/ and vectors/ stay at the root: the spec links to them and the site serves the schemas at their $id URLs. - pixi.toml: a `dev` feature and environment with setuptools, setuptools-scm and python-build, and `pixi run -e dev wheel`. - check.py scans src/paperspec for rule IDs; README, CONTRIBUTING, the FAQ, the overview, the vectors and tools READMEs and the changelog point at the new path. The FAQ no longer says the oracle is not a library. pixi.lock is not re-solved in this commit: with a dynamic version, pixi builds the package metadata to lock, which needs the conda environment installed. Run `pixi lock` and commit the result before pushing; the site workflow installs with --locked. pixi run check passes; no computed value, vector, fixture, schema or SQL file changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
40 files changed
+134
-84
No files matched your search
@@ -1,155 +0,0 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user