2026-08-17 14:57:41 -05:00

84 lines
2.8 KiB
Python

"""JSON in and out of the scripts, stricter than the json module in both directions.
Input is model-written and output is read by other tools, so load() refuses
what json.load admits but a conforming reader does not (NaN, Infinity, a
number that overflows to infinity, nesting past the interpreter's limit), and
text() keeps non-ASCII readable while escaping what UTF-8 or a JSONL line
reader cannot take (an unpaired surrogate, the Unicode line separators).
"""
from __future__ import annotations
import json
import math
import re
from collections.abc import Mapping
from typing import TYPE_CHECKING, NoReturn, cast
if TYPE_CHECKING:
import os
from typing import TypeGuard
JsonMap = Mapping[str, object]
# The separators a naive line reader would split a JSONL record on.
_SEPARATOR_ESCAPES = {0x85: "\\u0085", 0x2028: "\\u2028", 0x2029: "\\u2029"}
# The code units UTF-8 cannot encode; surrogateescape mints them from bytes.
_SURROGATES = re.compile(r"[\ud800-\udfff]")
def is_map(value: object) -> TypeGuard[JsonMap]:
"""Whether `value` is a JSON object."""
return isinstance(value, dict)
def is_list(value: object) -> TypeGuard[list[object]]:
"""Whether `value` is a JSON array."""
return isinstance(value, list)
def is_str(value: object) -> TypeGuard[str]:
"""Whether `value` is a JSON string."""
return isinstance(value, str)
def is_int(value: object) -> TypeGuard[int]:
"""Whether `value` is a JSON integer; JSON's booleans are not numbers."""
return isinstance(value, int) and not isinstance(value, bool)
def has_lone_surrogate(value: str) -> bool:
"""Whether `value` holds an unpaired surrogate, the one code unit UTF-8 cannot encode."""
return _SURROGATES.search(value) is not None
def _refuse(token: str) -> NoReturn:
msg = f"{token} is not JSON"
raise ValueError(msg)
def _finite(digits: str) -> float:
value = float(digits)
if not math.isfinite(value):
msg = f"{digits} is out of range"
raise ValueError(msg)
return value
def load(path: str | os.PathLike[str]) -> object:
"""The JSON value in `path`; OSError if unreadable, ValueError if not strict JSON."""
with open(path, encoding="utf-8") as handle:
try:
return cast("object", json.load(handle, parse_constant=_refuse, parse_float=_finite))
except RecursionError as error:
msg = "nested too deeply"
raise ValueError(msg) from error
def text(value: object, indent: int | None = None) -> str:
"""`value` as UTF-8-encodable JSON text: non-ASCII kept, finite only, separators escaped."""
dumped = json.dumps(value, ensure_ascii=False, allow_nan=False, indent=indent)
return _SURROGATES.sub(
lambda match: f"\\u{ord(match.group()):04x}", dumped.translate(_SEPARATOR_ESCAPES)
)