strictjson.py 2.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. """JSON in and out of the scripts, stricter than the json module in both directions.
  2. Input is model-written and output is read by other tools, so load() refuses
  3. what json.load admits but a conforming reader does not (NaN, Infinity, a
  4. number that overflows to infinity, nesting past the interpreter's limit), and
  5. text() keeps non-ASCII readable while escaping what UTF-8 or a JSONL line
  6. reader cannot take (an unpaired surrogate, the Unicode line separators).
  7. """
  8. from __future__ import annotations
  9. import json
  10. import math
  11. import re
  12. from collections.abc import Mapping
  13. from typing import TYPE_CHECKING, NoReturn, cast
  14. # pyright: reportMissingModuleSource=false
  15. if TYPE_CHECKING:
  16. from pathlib import Path
  17. from typing_extensions import TypeGuard
  18. JsonMap = Mapping[str, object]
  19. # The separators a naive line reader would split a JSONL record on.
  20. _SEPARATOR_ESCAPES = {0x85: "\\u0085", 0x2028: "\\u2028", 0x2029: "\\u2029"}
  21. # The code units UTF-8 cannot encode; surrogateescape mints them from bytes.
  22. _SURROGATES = re.compile(r"[\ud800-\udfff]")
  23. def is_map(value: object) -> TypeGuard[JsonMap]:
  24. """Whether `value` is a JSON object."""
  25. return isinstance(value, dict)
  26. def is_list(value: object) -> TypeGuard[list[object]]:
  27. """Whether `value` is a JSON array."""
  28. return isinstance(value, list)
  29. def is_str(value: object) -> TypeGuard[str]:
  30. """Whether `value` is a JSON string."""
  31. return isinstance(value, str)
  32. def is_int(value: object) -> TypeGuard[int]:
  33. """Whether `value` is a JSON integer; JSON's booleans are not numbers."""
  34. return isinstance(value, int) and not isinstance(value, bool)
  35. def has_lone_surrogate(value: str) -> bool:
  36. """Whether `value` holds an unpaired surrogate, the one code unit UTF-8 cannot encode."""
  37. return _SURROGATES.search(value) is not None
  38. def _refuse(token: str) -> NoReturn:
  39. msg = f"{token} is not JSON"
  40. raise ValueError(msg)
  41. def _finite(digits: str) -> float:
  42. value = float(digits)
  43. if not math.isfinite(value):
  44. msg = f"{digits} is out of range"
  45. raise ValueError(msg)
  46. return value
  47. def loads(document: str) -> object:
  48. """The JSON value in `document`; ValueError if not strict JSON."""
  49. try:
  50. return cast("object", json.loads(document, parse_constant=_refuse, parse_float=_finite))
  51. except RecursionError as error:
  52. msg = "nested too deeply"
  53. raise ValueError(msg) from error
  54. def load(path: Path) -> object:
  55. """The JSON value in `path`; OSError if unreadable, ValueError if not strict JSON."""
  56. return loads(path.read_text(encoding="utf-8"))
  57. def text(value: object, indent: int | None = None) -> str:
  58. """`value` as UTF-8-encodable JSON text: non-ASCII kept, finite only, separators escaped."""
  59. dumped = json.dumps(value, ensure_ascii=False, allow_nan=False, indent=indent)
  60. return _SURROGATES.sub(
  61. lambda match: f"\\u{ord(match.group()):04x}", dumped.translate(_SEPARATOR_ESCAPES)
  62. )