From e15bcf029b2cc898b68a452b75a3676881dabc41 Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Fri, 5 Jun 2026 03:47:49 +0200 Subject: [PATCH] hashable class without deriving from HasUhash # Conflicts: # src/ewokscore/hashing.py --- src/ewokscore/hashing.py | 178 +++++++++++++++-------------- src/ewokscore/persistence/proxy.py | 6 +- 2 files changed, 94 insertions(+), 90 deletions(-) diff --git a/src/ewokscore/hashing.py b/src/ewokscore/hashing.py index 2d7f1bc..a05e15b 100644 --- a/src/ewokscore/hashing.py +++ b/src/ewokscore/hashing.py @@ -1,3 +1,5 @@ +"""Universal hashing independent of the current process""" + import hashlib import random from collections.abc import Iterable @@ -14,32 +16,6 @@ from . import missing_data -def classhashdata(cls: Type) -> bytes: - return qualname(cls).encode() - - -def multitype_sorted(sequence: Iterable, key=None) -> list: - try: - return sorted(sequence, key=key) - except TypeError: - pass - if key is None: - - def key(item): - return item - - adict = dict() - for item in sequence: - typename = type(key(item)).__name__ - adict.setdefault(typename, list()).append(item) - - return [ - item - for _, items in sorted(adict.items(), key=lambda tpl: tpl[0]) - for item in sorted(items, key=key) - ] - - class UniversalHash: def __init__(self, hexdigest: Union[str, bytes]): if isinstance(hexdigest, bytes): @@ -65,54 +41,87 @@ def __lt__(self, other): return str(self) < str(other) -def uhash(value, _hash=None) -> UniversalHash: +def uhash(value) -> UniversalHash: """Universial hash (as opposed to python's `hash`).""" - # Avoid using python's hash! - bdigest = _hash is None - if bdigest: - _hash = hashlib.sha256() - _hash.update(classhashdata(type(value))) - if value is None: - pass - elif isinstance(value, HasUhash): - _hash.update(repr(value.uhash).encode()) - elif isinstance(value, UniversalHash): - _hash.update(repr(value).encode()) - elif isinstance(value, bytes): - _hash.update(value) - elif isinstance(value, str): - _hash.update(value.encode()) - elif isinstance(value, int): - _hash.update(hex(value).encode()) - elif isinstance(value, float): - _hash.update(value.hex().encode()) - elif isinstance(value, (numpy.ndarray, numpy.number)): - _hash.update(value.tobytes()) - elif isinstance(value, Mapping): - lst = multitype_sorted(value.items(), key=lambda item: item[0]) - if lst: - keys, values = zip(*lst) + if hasattr(value, "__uhash__"): + v = value.__uhash__() + if isinstance(v, UniversalHash): + return v + + _hash = hashlib.sha256() + _to_hash = [value] + while _to_hash: + value = _to_hash.pop() + _hash.update(_classhashdata(type(value))) + if value is None: + pass + elif isinstance(value, UniversalHash): + _hash.update(repr(value).encode()) + elif hasattr(value, "__uhash__"): + _to_hash.append(value.__uhash__()) + elif isinstance(value, bytes): + _hash.update(value) + elif isinstance(value, str): + _hash.update(value.encode()) + elif isinstance(value, int): + _hash.update(hex(value).encode()) + elif isinstance(value, float): + _hash.update(value.hex().encode()) + elif isinstance(value, (numpy.ndarray, numpy.number)): + _hash.update(value.tobytes()) + elif isinstance(value, Mapping): + lst = _multitype_sorted(value.items(), key=lambda item: item[0]) + if lst: + keys, values = zip(*lst) + else: + keys = values = list() + _to_hash.append(keys) + _to_hash.append(values) + elif isinstance(value, Set): + values = _multitype_sorted(value) + _to_hash.append(values) + elif isinstance(value, Iterable): + _to_hash.extend(value) else: - keys = values = list() - uhash(keys, _hash=_hash) - uhash(values, _hash=_hash) - elif isinstance(value, Set): - values = multitype_sorted(value) - uhash(values, _hash=_hash) - elif isinstance(value, Iterable): - # Ordered - for v in value: - uhash(v, _hash=_hash) - else: - # TODO: register custom types - raise TypeError(f"cannot uhash {value} (type: {type(value)})") - if bdigest: - return UniversalHash(_hash.hexdigest()) + raise TypeError(f"universal unhashable type: {type(value)}") + + return UniversalHash(_hash.hexdigest()) + + +def _classhashdata(cls: Type) -> bytes: + return qualname(cls).encode() + + +def _multitype_sorted(sequence: Iterable, key=None) -> list: + try: + return sorted(sequence, key=key) + except TypeError: + pass + if key is None: + + def key(item): + return item + + adict = dict() + for item in sequence: + typename = type(key(item)).__name__ + adict.setdefault(typename, list()).append(item) + + return [ + item + for _, items in sorted(adict.items(), key=lambda tpl: tpl[0]) + for item in sorted(items, key=key) + ] class HasUhash: @property def uhash(self) -> Optional[UniversalHash]: + # TODO: this should be hash(self) + return self.__uhash__() + + def __uhash__(self) -> Optional[UniversalHash]: + # TODO: can return Any raise NotImplementedError def __hash__(self): @@ -124,13 +133,7 @@ def __hash__(self): return hash(uhash) def __eq__(self, other): - if isinstance(other, HasUhash): - uhash = other.uhash - elif isinstance(other, UniversalHash): - uhash = other - else: - raise TypeError(other, type(other)) - return self.uhash == uhash + return uhash(self) == uhash(other) def _get_repr_data(self) -> dict: data = dict() @@ -184,6 +187,10 @@ def __init__( pre_uhash: Optional[PreUhashTypes] = None, instance_nonce: Optional[Any] = None, ): + self.__pre_uhash: Union[None, UniversalHash, HasUhash] = None + self.__original_pre_uhash: Union[None, UniversalHash, HasUhash] = None + self.__instance_nonce: Optional[Any] = instance_nonce + self.__original__instance_nonce: Optional[Any] = instance_nonce self.set_uhash_init(pre_uhash=pre_uhash, instance_nonce=instance_nonce) def __init_subclass__(subcls, version=None, **kwargs): @@ -203,6 +210,16 @@ def set_uhash_init( self.__instance_nonce = instance_nonce self.__original__instance_nonce = instance_nonce + def __set_pre_uhash(self, pre_uhash): + if pre_uhash is None: + self.__pre_uhash = None + elif isinstance(pre_uhash, (str, bytes)): + self.__pre_uhash = UniversalHash(pre_uhash) + elif isinstance(pre_uhash, (UniversalHash, HasUhash)): + self.__pre_uhash = pre_uhash + else: + self.__pre_uhash = uhash(pre_uhash) + def get_uhash_init(self, serialize=False): pre_uhash = self.__original_pre_uhash if serialize: @@ -215,16 +232,6 @@ def get_uhash_init(self, serialize=False): "instance_nonce": self.__original__instance_nonce, } - def __set_pre_uhash(self, pre_uhash): - if pre_uhash is None: - self.__pre_uhash = None - elif isinstance(pre_uhash, (str, bytes)): - self.__pre_uhash = UniversalHash(pre_uhash) - elif isinstance(pre_uhash, (UniversalHash, HasUhash)): - self.__pre_uhash = pre_uhash - else: - self.__pre_uhash = uhash(pre_uhash) - @classmethod def class_nonce(cls): return cls.__CLASS_NONCE @@ -259,8 +266,7 @@ def cleanup_references(self): self.__pre_uhash = pre_uhash self.__original_pre_uhash = pre_uhash - @property - def uhash(self) -> Optional[UniversalHash]: + def __uhash__(self) -> Union[None, UniversalHash, HasUhash]: _uhash = self.__pre_uhash if _uhash is None: data = self._uhash_data() diff --git a/src/ewokscore/persistence/proxy.py b/src/ewokscore/persistence/proxy.py index 4554461..a218782 100644 --- a/src/ewokscore/persistence/proxy.py +++ b/src/ewokscore/persistence/proxy.py @@ -56,8 +56,7 @@ def __eq__(self, other): def __copy__(self): return type(self)(self.__uri, self.__uhash) - @property - def uhash(self) -> UniversalHash: + def __uhash__(self) -> UniversalHash: return self.__uhash def serialize(self) -> Dict[str, str]: @@ -149,8 +148,7 @@ def deserialize(self, data: Dict[str, str]): return None return self.instantiate(uri=uri) - @property - def uhash(self) -> Optional[UniversalHash]: + def __uhash__(self) -> Optional[UniversalHash]: if self.is_fixed_uri: return self.__fixed_uri.uhash elif isinstance(self.__uhash_source, HasUhash):