77 import ampform.sympy._cache
88"""
99
10+ # cspell:ignore pickler
1011from __future__ import annotations
1112
1213import hashlib
1314import inspect
15+ import io
1416import logging
1517import os
1618import pickle # ruff: ignore[suspicious-pickle-import]
1719import re
1820import sys
1921import tempfile
2022from collections import abc
23+ from contextlib import suppress
2124from functools import cache , wraps
2225from importlib .metadata import PackageNotFoundError , version
2326from pathlib import Path
24- from typing import TYPE_CHECKING , overload
27+ from typing import TYPE_CHECKING , NamedTuple , overload
2528
29+ import sympy as sp
2630from frozendict import frozendict
2731
2832if TYPE_CHECKING :
29- from collections .abc import Hashable
33+ from collections .abc import Hashable , Iterable
3034 from io import BufferedReader
3135
3236 from _typeshed import SupportsWrite
@@ -209,7 +213,7 @@ def _get_cache_dir() -> Path:
209213
210214
211215@cache
212- def _warn_once (msg ):
216+ def _warn_once (msg , / ):
213217 _LOGGER .warning (msg )
214218
215219
@@ -239,31 +243,92 @@ def get_system_cache_directory() -> str:
239243
240244
241245@cache
242- def get_readable_hash (obj : Hashable ) -> str :
246+ def get_readable_hash (obj : Hashable , / ) -> str :
243247 """Get a human-readable hash of any hashable Python object.
244248
249+ The hash follows from the value of the object, not from the identity of the parts it
250+ is built from. Two SymPy expressions that compare equal therefore hash the same, no
251+ matter what was constructed before them or in which process, which is what makes the
252+ hash usable as a cache key in :func:`.cache_to_disk`.
253+
254+ A `set` or `dict` handed to this function directly is serialized in iteration order,
255+ which depends on :code:`PYTHONHASHSEED` when its elements or keys are `str`. Pass it
256+ through :func:`.make_hashable` first, as :func:`.cache_to_disk` does, to put it in a
257+ fixed order.
258+
245259 Args:
246260 obj: Any hashable object, mutable or immutable, to be hashed.
247261 """
248- b = to_bytes (obj )
249- h = hashlib .md5 (b , usedforsecurity = False )
250- return h .hexdigest ()
262+ return hashlib .md5 (to_bytes (obj ), usedforsecurity = False ).hexdigest ()
263+
251264
265+ def to_bytes (obj , / ) -> bytes :
266+ """Convert any Python object to `bytes` with :mod:`pickle`.
252267
253- def to_bytes (obj ) -> bytes :
254- """Convert any Python object to `bytes` using :func:`pickle.dumps`."""
268+ The bytes depend on the value of the object rather than on which of its parts happen
269+ to be the same instance. See :func:`.get_readable_hash` for what that guarantees.
270+ """
255271 if isinstance (obj , (bytes , bytearray )):
256272 return obj
257- return pickle .dumps (obj , protocol = pickle .HIGHEST_PROTOCOL )
273+ stream = io .BytesIO ()
274+ _dump_deterministically (obj , stream )
275+ return stream .getvalue ()
276+
277+
278+ def _dump_deterministically (obj , / , stream : SupportsWrite [bytes ]) -> None :
279+ """Pickle an object so that the bytes depend on its value alone."""
280+ _DeterministicPickler (stream ).dump (obj )
281+
282+
283+ class _DeterministicPickler (pickle ._Pickler ): # ruff: ignore[private-member-access]
284+ """Pickler whose output does not depend on object identity.
285+
286+ A pickle stores a repeated object as a back-reference to the first time it was
287+ written, keyed on identity. SymPy hands out a cached instance for equal expressions,
288+ but its cache is a bounded LRU (:code:`SYMPY_CACHE_SIZE`), so which sub-expressions
289+ are the *same* instance depends on what was constructed before and in which order.
290+ Equal expressions are therefore mapped to one representative here, which makes the
291+ back-references depend on the expression rather than on the cache state.
292+
293+ Two SymPy objects that compare equal always carry the same
294+ :code:`_hashable_content()`, which for `.unevaluated` classes includes the arguments
295+ that are not sympified, so equal objects also pickle to the same bytes.
296+
297+ Only those canonicalized SymPy objects are memoized. Anything else is written out in
298+ full on each occurrence, because whether two equal non-SymPy objects are one shared
299+ instance depends on caches and string interning elsewhere.
300+
301+ The pure-Python pickler is subclassed because the C accelerator does not dispatch to
302+ these overrides.
303+ """
304+
305+ def __init__ (self , stream : SupportsWrite [bytes ]) -> None :
306+ super ().__init__ (stream , protocol = pickle .HIGHEST_PROTOCOL )
307+ self ._representatives : dict [tuple [type , Any ], Any ] = {}
308+
309+ def save (self , obj , * args , ** kwargs ) -> None :
310+ if isinstance (obj , sp .Basic ):
311+ with suppress (TypeError ):
312+ obj = self ._representatives .setdefault ((type (obj ), obj ), obj )
313+ super ().save (obj , * args , ** kwargs )
314+
315+ def memoize (self , obj ) -> None :
316+ if isinstance (obj , sp .Basic ):
317+ super ().memoize (obj )
258318
259319
260320def make_hashable (* args ) -> Hashable :
261321 """Make a hashable object from any Python object.
262322
323+ Sets and dictionaries are put in a fixed order, because a `set` iterates in an order
324+ that depends on :code:`PYTHONHASHSEED` for `str` elements, and a `dict` built by
325+ iterating one inherits that order. A cache key built from them would otherwise
326+ differ in every process.
327+
263328 >>> make_hashable("a", 1, {"b": 2}, {3, 4})
264- ('a', 1, frozendict.frozendict({'b': 2}), frozenset({ 3, 4} ))
329+ ('a', 1, frozendict.frozendict({'b': 2}), _SortedSet(items=( 3, 4) ))
265330 >>> make_hashable({"a": {"sub-key": {1, 2, 3}, "b": [4, 5]}})
266- frozendict.frozendict({'a': frozendict.frozendict({'sub-key ': frozenset({1, 2, 3} ), 'b ': (4, 5 )})})
331+ frozendict.frozendict({'a': frozendict.frozendict({'b ': (4, 5 ), 'sub-key ': _SortedSet(items=(1, 2, 3) )})})
267332 >>> make_hashable("already-hashable")
268333 'already-hashable'
269334 """
@@ -272,15 +337,30 @@ def make_hashable(*args) -> Hashable:
272337 return tuple (_make_hashable_impl (x ) for x in args )
273338
274339
275- def _make_hashable_impl (obj ) -> Hashable :
340+ def _make_hashable_impl (obj , / ) -> Hashable :
276341 if isinstance (obj , abc .Mapping ):
277- return frozendict ({k : _make_hashable_impl (v ) for k , v in obj .items ()})
342+ keys = _sorted_deterministically (obj .keys ())
343+ return frozendict ({k : _make_hashable_impl (obj [k ]) for k in keys })
278344 if isinstance (obj , str ):
279345 return obj
280346 if isinstance (obj , abc .Iterable ):
281347 hashable_items = (_make_hashable_impl (x ) for x in obj )
282348 if isinstance (obj , abc .Sequence ):
283349 return tuple (hashable_items )
284350 if isinstance (obj , set ):
285- return frozenset ( hashable_items )
351+ return _SortedSet ( _sorted_deterministically ( hashable_items ) )
286352 return obj
353+
354+
355+ class _SortedSet (NamedTuple ):
356+ """Order-normalized stand-in for a `set`, tagged so it cannot pass for a `tuple`."""
357+
358+ items : tuple
359+
360+
361+ def _sorted_deterministically (items : Iterable [T ], / ) -> tuple [T , ...]:
362+ """Sort items, falling back to their serialization when they cannot be compared."""
363+ try :
364+ return tuple (sorted (items ))
365+ except TypeError :
366+ return tuple (sorted (items , key = to_bytes ))
0 commit comments