63 lines
1.9 KiB
Python
63 lines
1.9 KiB
Python
"""Canonical serialization and stable SHA-256 helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Mapping
|
|
|
|
import numpy as np
|
|
|
|
|
|
def to_jsonable(value: Any) -> Any:
|
|
"""Convert supported scientific-Python values to strict JSON values."""
|
|
if dataclasses.is_dataclass(value):
|
|
return to_jsonable(dataclasses.asdict(value))
|
|
if isinstance(value, Path):
|
|
return str(value)
|
|
if isinstance(value, np.ndarray):
|
|
return to_jsonable(value.tolist())
|
|
if isinstance(value, np.generic):
|
|
return to_jsonable(value.item())
|
|
if isinstance(value, Mapping):
|
|
return {str(key): to_jsonable(item) for key, item in value.items()}
|
|
if isinstance(value, (tuple, list)):
|
|
return [to_jsonable(item) for item in value]
|
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
return value
|
|
raise TypeError(f"Unsupported value for canonical JSON: {type(value).__name__}")
|
|
|
|
|
|
def canonical_json_bytes(value: Any) -> bytes:
|
|
"""Return deterministic UTF-8 JSON bytes, rejecting NaN and infinity."""
|
|
return json.dumps(
|
|
to_jsonable(value),
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
allow_nan=False,
|
|
).encode("utf-8")
|
|
|
|
|
|
def stable_hash(value: Any, *, prefix: str = "") -> str:
|
|
"""Hash a value using canonical JSON and an optional domain prefix."""
|
|
digest = hashlib.sha256()
|
|
if prefix:
|
|
digest.update(prefix.encode("utf-8"))
|
|
digest.update(b"\0")
|
|
digest.update(canonical_json_bytes(value))
|
|
return digest.hexdigest()
|
|
|
|
|
|
def file_sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
while True:
|
|
block = stream.read(1024 * 1024)
|
|
if not block:
|
|
break
|
|
digest.update(block)
|
|
return digest.hexdigest()
|