Skip to content

Commit 50a1f9d

Browse files
committed
Move xxhash to the "performance" extra
1 parent c569a5d commit 50a1f9d

3 files changed

Lines changed: 162 additions & 9 deletions

File tree

hamilton/caching/fingerprinting.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@
3333
implementation should pass the `depth` parameter to prevent `RecursionError`.
3434
"""
3535

36+
from __future__ import annotations
37+
3638
import base64
3739
import datetime
3840
import functools
3941
import logging
4042
import sys
4143
from collections.abc import Mapping, Sequence, Set
4244

43-
import xxhash
44-
4545
from hamilton.experimental import h_databackends
4646

4747
# NoneType is introduced in Python 3.10
@@ -50,6 +50,31 @@
5050
except ImportError:
5151
NoneType = type(None)
5252

53+
TYPE_CHECKING: bool = False
54+
if TYPE_CHECKING:
55+
from collections.abc import Buffer, Callable
56+
from typing import Protocol
57+
58+
class Hash(Protocol):
59+
def digest(self) -> bytes: ...
60+
61+
62+
hash_func: Callable[[str | Buffer], Hash]
63+
try:
64+
# xxh3_128 produces a 16-byte digest (24 base64url chars, the same width as the
65+
# md5 it replaces) while running substantially faster on buffer-bound paths.
66+
import xxhash
67+
68+
hash_func = xxhash.xxh3_128
69+
except (ModuleNotFoundError, AttributeError):
70+
# ModuleNotFoundError covers xxhash not being installed; AttributeError
71+
# covers an xxhash older than 0.8.0 (which added xxh3_128).
72+
# usedforsecurity=False avoids a ValueError on FIPS-mode Python; it
73+
# doesn't change the digest.
74+
import functools
75+
import hashlib
76+
77+
hash_func = functools.partial(hashlib.md5, usedforsecurity=False)
5378

5479
logger = logging.getLogger("hamilton.caching")
5580

@@ -78,16 +103,20 @@ def _compact_hash(digest: bytes) -> str:
78103

79104

80105
def _hash_bytes(data: bytes) -> str:
81-
"""Hash raw bytes with the non-cryptographic xxh3_128 algorithm and
82-
compact-encode the digest.
106+
"""Hash raw bytes and compact-encode the digest.
83107
84108
All hashing in this module routes through this single helper so the
85109
underlying hashing algorithm can be changed in exactly one place.
86-
xxh3_128 produces a 16-byte digest (24 base64url chars, the same width
87-
as the md5 it replaces) while running substantially faster on
88-
buffer-bound paths.
110+
111+
Uses the non-cryptographic xxh3_128 algorithm when the optional
112+
``xxhash`` package is installed (see the ``performance`` extra),
113+
falling back to hashlib's md5 otherwise. Both produce a 16-byte digest
114+
(24 base64url chars), so digest width is unaffected either way, but the
115+
two algorithms don't produce the same bytes for the same input:
116+
fingerprints are stable within an environment, not across installs with
117+
a different backend.
89118
"""
90-
return _compact_hash(xxhash.xxh3_128(data).digest())
119+
return _compact_hash(hash_func(data).digest())
91120

92121

93122
@functools.singledispatch

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ dependencies = [
5151
"pandas",
5252
"typing_extensions > 4.0.0",
5353
"typing_inspect",
54-
"xxhash>=0.8.0",
5554
]
5655

5756
[project.optional-dependencies]
@@ -71,6 +70,10 @@ experiments = [
7170
lsp = ["apache-hamilton-lsp"]
7271
openlineage = ["openlineage-python"]
7372
pandera = ["pandera"]
73+
performance = [
74+
# Internal performance boosters go here
75+
"xxhash>=0.8.0",
76+
]
7477
pydantic = ["pydantic>=2.0"]
7578
pyspark = [
7679
# we have to run these dependencies because Spark does not check to ensure the right target was called
@@ -135,6 +138,7 @@ test = [
135138
"xgboost; python_version < '3.14'",
136139
"xlsx2csv", # for excel data loader
137140
"xlsxwriter", # Excel export requires 'xlsxwriter'
141+
"xxhash>=0.8.0", # exercises the high-performance fingerprinting path in tests
138142
]
139143
docs = [
140144
{include-group = "dev"},

tests/caching/test_fingerprinting.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,28 @@
2020
the original `hash_value()` and the `hash_primitive()` functions.
2121
"""
2222

23+
import functools
24+
import hashlib
25+
import importlib
26+
import sys
27+
import types
28+
2329
import numpy as np
2430
import pandas as pd
2531
import pytest
2632

2733
from hamilton.caching import fingerprinting
2834

2935

36+
@pytest.fixture
37+
def force_md5_backend(monkeypatch):
38+
"""Force the hashlib.md5 fallback backend, regardless of whether xxhash
39+
is installed in the current environment."""
40+
monkeypatch.setattr(
41+
fingerprinting, "hash_func", functools.partial(hashlib.md5, usedforsecurity=False)
42+
)
43+
44+
3045
def test_hash_none():
3146
fingerprint = fingerprinting.hash_value(None)
3247
assert fingerprint == "<none>"
@@ -226,6 +241,111 @@ def test_hash_numpy():
226241
assert fingerprint == expected_hash
227242

228243

244+
# ---------------------------------------------------------------------------
245+
# hashlib.md5 fallback backend
246+
#
247+
# These mirror the pinned tests above but force `hash_func` to the hashlib.md5
248+
# fallback (via the `force_md5_backend` fixture) so the fallback path used
249+
# when `xxhash` isn't installed is verified regardless of what's installed in
250+
# the environment running the suite. Expected digests are the pre-xxh3_128
251+
# values this module used before xxhash became the default backend.
252+
# ---------------------------------------------------------------------------
253+
254+
255+
@pytest.mark.usefixtures("force_md5_backend")
256+
@pytest.mark.parametrize(
257+
("obj", "expected_hash"),
258+
[
259+
("hello-world", "L1Q1Kh6_t1atHO_H8RbBeA=="),
260+
(17.31231, "mJPTpPyXDSZgU-u8NuztIQ=="),
261+
(16474, "6MgAp1NbMW0ZZpe_8iKVsg=="),
262+
(True, "J2eGynSuIpd5bwVQzO9VVg=="),
263+
(b"\x951!\x89u=\xe6\xadG\xdf", "d1DufDgRQmqi9Kt4Z2PeUQ=="),
264+
],
265+
)
266+
def test_hash_primitive_md5_fallback(obj, expected_hash):
267+
fingerprint = fingerprinting.hash_primitive(obj)
268+
assert fingerprint == expected_hash
269+
270+
271+
@pytest.mark.usefixtures("force_md5_backend")
272+
@pytest.mark.parametrize(
273+
("obj", "expected_hash"),
274+
[
275+
([0, True, "hello-world"], "mlOjj4yeCrSDFSn5zgdEIg=="),
276+
((17.0, False, "world"), "BcRSGfyKeIOdym9B6TmAyQ=="),
277+
],
278+
)
279+
def test_hash_sequence_md5_fallback(obj, expected_hash):
280+
fingerprint = fingerprinting.hash_sequence(obj)
281+
assert fingerprint == expected_hash
282+
283+
284+
@pytest.mark.usefixtures("force_md5_backend")
285+
def test_hash_ordered_mapping_md5_fallback():
286+
obj = {0: True, "key": "value", 17.0: None}
287+
expected_hash = "GyxyI9-pq-EJJvSAIN509g=="
288+
fingerprint = fingerprinting.hash_mapping(obj, ignore_order=False)
289+
assert fingerprint == expected_hash
290+
291+
292+
@pytest.mark.usefixtures("force_md5_backend")
293+
def test_hash_unordered_mapping_md5_fallback():
294+
obj = {0: True, "key": "value", 17.0: None}
295+
expected_hash = "cDuuL2eA3DaSWlWW3u7o9g=="
296+
fingerprint = fingerprinting.hash_mapping(obj, ignore_order=True)
297+
assert fingerprint == expected_hash
298+
299+
300+
@pytest.mark.usefixtures("force_md5_backend")
301+
def test_hash_set_md5_fallback():
302+
obj = {0, True, "key", "value", 17.0, None}
303+
expected_hash = "E_f_tjbi6qn7KL3NUCZayg=="
304+
fingerprint = fingerprinting.hash_set(obj)
305+
assert fingerprint == expected_hash
306+
307+
308+
@pytest.mark.usefixtures("force_md5_backend")
309+
def test_hash_numpy_md5_fallback():
310+
array = np.array([[0, 1], [2, 3]], dtype=np.int64)
311+
expected_hash = "024zwZIcWy6r4dlX4AMTow=="
312+
fingerprint = fingerprinting.hash_value(array)
313+
assert fingerprint == expected_hash
314+
315+
316+
# ---------------------------------------------------------------------------
317+
# Import-time backend resolution
318+
#
319+
# These reload the module to actually exercise the try/except at import time
320+
# (as opposed to `force_md5_backend`, which only overrides the already-resolved
321+
# `hash_func`), then restore the module to its real, ambient-environment state
322+
# so later tests aren't affected.
323+
# ---------------------------------------------------------------------------
324+
325+
326+
def test_falls_back_when_xxhash_not_installed(monkeypatch):
327+
"""Simulate xxhash being absent: `import xxhash` raises ModuleNotFoundError."""
328+
monkeypatch.setitem(sys.modules, "xxhash", None)
329+
try:
330+
reloaded = importlib.reload(fingerprinting)
331+
assert reloaded.hash_func.func is hashlib.md5
332+
assert reloaded.hash_func.keywords == {"usedforsecurity": False}
333+
finally:
334+
importlib.reload(fingerprinting)
335+
336+
337+
def test_falls_back_when_xxhash_lacks_xxh3_128(monkeypatch):
338+
"""Simulate an xxhash older than 0.8.0, which doesn't define xxh3_128."""
339+
stub = types.ModuleType("xxhash")
340+
monkeypatch.setitem(sys.modules, "xxhash", stub)
341+
try:
342+
reloaded = importlib.reload(fingerprinting)
343+
assert reloaded.hash_func.func is hashlib.md5
344+
assert reloaded.hash_func.keywords == {"usedforsecurity": False}
345+
finally:
346+
importlib.reload(fingerprinting)
347+
348+
229349
def test_hash_numpy_different_shapes_differ():
230350
"""Arrays with the same raw bytes but different shapes must hash differently."""
231351
a = np.array([1, 2, 3, 4, 5, 6])

0 commit comments

Comments
 (0)