42 lines
1.4 KiB
Python
42 lines
1.4 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
def _jcs(value: Any) -> str:
|
|
"""Deterministic JSON close to RFC 8785 for this integer/string DTO domain."""
|
|
if value is None:
|
|
return "null"
|
|
if value is True:
|
|
return "true"
|
|
if value is False:
|
|
return "false"
|
|
if isinstance(value, str):
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
if isinstance(value, int):
|
|
return str(value)
|
|
if isinstance(value, float):
|
|
if not math.isfinite(value):
|
|
raise ValueError("non-finite numbers are not JSON canonicalizable")
|
|
raise TypeError("floating point values are forbidden in safety fingerprints")
|
|
if isinstance(value, list):
|
|
return "[" + ",".join(_jcs(item) for item in value) + "]"
|
|
if isinstance(value, dict):
|
|
keys = sorted(value, key=lambda key: key.encode("utf-16be"))
|
|
return "{" + ",".join(f"{_jcs(key)}:{_jcs(value[key])}" for key in keys) + "}"
|
|
raise TypeError(f"unsupported fingerprint type: {type(value).__name__}")
|
|
|
|
|
|
def canonical_json(model: BaseModel | dict[str, Any]) -> bytes:
|
|
value = model.model_dump(mode="json") if isinstance(model, BaseModel) else model
|
|
return _jcs(value).encode("utf-8")
|
|
|
|
|
|
def fingerprint(model: BaseModel | dict[str, Any]) -> bytes:
|
|
return hashlib.sha256(canonical_json(model)).digest()
|