-
Notifications
You must be signed in to change notification settings - Fork 204
Expand file tree
/
Copy pathtest_fingerprinting.py
More file actions
309 lines (233 loc) · 11.1 KB
/
Copy pathtest_fingerprinting.py
File metadata and controls
309 lines (233 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Due to the recursive nature of hashing of sequences, mappings, and other
complex types, many tests are not "true" unit tests. The base cases are
the original `hash_value()` and the `hash_primitive()` functions.
"""
import numpy as np
import pandas as pd
import pytest
from hamilton.caching import fingerprinting
def test_hash_none():
fingerprint = fingerprinting.hash_value(None)
assert fingerprint == "<none>"
def test_hash_no_dict_attribute():
"""Classes without a __dict__ attribute can't be hashed.
during the base case.
"""
class Foo:
__slots__ = ()
obj = Foo()
assert not hasattr(obj, "__dict__")
fingerprint = fingerprinting.hash_value(obj)
assert fingerprint == fingerprinting.UNHASHABLE
def test_empty_dict_attr_is_unhashable():
"""Classes with an empty __dict__ can't be hashed during the base case."""
class Foo: ... # noqa: E701
obj = Foo()
assert obj.__dict__ == {}
fingerprint = fingerprinting.hash_value(obj)
assert fingerprint == fingerprinting.UNHASHABLE
def test_hash_recursively():
"""Classes without a specialized hash function are hashed recursively
via their __dict__ attribute.
"""
class Foo:
def __init__(self, obj):
self.foo = "foo"
self.obj = obj
foo0 = Foo(obj=None)
foo1 = Foo(obj=foo0)
foo2 = Foo(obj=foo1)
foo0_dict = {"foo": "foo", "obj": None}
foo1_dict = {"foo": "foo", "obj": foo0_dict}
foo2_dict = {"foo": "foo", "obj": foo1_dict}
assert foo0.__dict__ == foo0_dict
# NOTE foo2.__dict__ != foo2_dict, because foo2.__dict__ holds
# a reference to the object foo1, which is not the case for foo2_dict
fingerprint0 = fingerprinting.hash_value(foo0)
assert fingerprint0 == fingerprinting.hash_value(foo0_dict)
fingerprint1 = fingerprinting.hash_value(foo1)
assert fingerprint1 == fingerprinting.hash_value(foo1_dict)
fingerprint2 = fingerprinting.hash_value(foo2)
assert fingerprint2 == fingerprinting.hash_value(foo2_dict)
def test_max_recursion_depth():
"""Set the max recursion depth to 0 to prevent any recursion.
After max depth, the default case should return UNHASHABLE.
"""
class Foo:
def __init__(self, obj):
self.foo = "foo"
self.obj = obj
foo0 = Foo(obj=None)
foo1 = Foo(obj=foo0)
foo2 = Foo(obj=foo1)
foo0_dict = {"foo": "foo", "obj": None}
assert foo0.__dict__ == foo0_dict
fingerprint0 = fingerprinting.hash_value(foo0)
assert fingerprint0 == fingerprinting.hash_value(foo0_dict)
fingerprinting.set_max_depth(1)
# equivalent after reaching max depth
fingerprint1 = fingerprinting.hash_value(foo1)
fingerprint2 = fingerprinting.hash_value(foo2)
assert fingerprint1 == fingerprint2
fingerprinting.set_max_depth(2)
# no longer equivalent after increasing max depth
fingerprint1 = fingerprinting.hash_value(foo1)
fingerprint2 = fingerprinting.hash_value(foo2)
assert fingerprint1 != fingerprint2
# ---------------------------------------------------------------------------
# Portability / algorithm-stability guard
#
# The tests below pin literal digests. They cover only types that hash
# deterministically across platforms and library versions: their digest is a
# function of the value's Python representation (or, for numpy, an explicit
# shape + dtype + raw bytes) and the hashing algorithm alone. Pinning them
# guards against an accidental change to the hashing algorithm and documents
# that the fingerprint is reproducible on other machines.
#
# Version-sensitive types (pandas / polars DataFrames, whose digest depends on
# library-version-specific dtype reprs and row-hash internals) are NOT pinned
# here; they are covered by the relational must-differ / must-match tests
# further down, which assert behavior rather than an exact digest.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("obj", "expected_hash"),
[
("hello-world", "EXXR8_e47ElS18aP2lThJA=="),
(17.31231, "tVUSIslYiBcW52c-7w4gvA=="),
(16474, "FAJ-iXM_Hwg9TCRreY8AyA=="),
(True, "qkJEg3-XQKmGWk5sWqmonw=="),
(b"\x951!\x89u=\xe6\xadG\xdf", "pPTyYkSU_x7NLB1Fp_YTyA=="),
],
)
def test_hash_primitive(obj, expected_hash):
fingerprint = fingerprinting.hash_primitive(obj)
assert fingerprint == expected_hash
@pytest.mark.parametrize(
("obj", "expected_hash"),
[
([0, True, "hello-world"], "I98OkNhfxtScJrYNTs4ZfQ=="),
((17.0, False, "world"), "catgOMSnsbQj1_KELNQscw=="),
],
)
def test_hash_sequence(obj, expected_hash):
fingerprint = fingerprinting.hash_sequence(obj)
assert fingerprint == expected_hash
def test_hash_equals_for_different_sequence_types():
list_obj = [0, True, "hello-world"]
tuple_obj = (0, True, "hello-world")
expected_hash = "I98OkNhfxtScJrYNTs4ZfQ=="
list_fingerprint = fingerprinting.hash_sequence(list_obj)
tuple_fingerprint = fingerprinting.hash_sequence(tuple_obj)
assert list_fingerprint == tuple_fingerprint == expected_hash
def test_hash_ordered_mapping():
obj = {0: True, "key": "value", 17.0: None}
expected_hash = "zX6MzhWGAOvxateHIPxOvA=="
fingerprint = fingerprinting.hash_mapping(obj, ignore_order=False)
assert fingerprint == expected_hash
def test_hash_mapping_where_order_matters():
obj1 = {0: True, "key": "value", 17.0: None}
obj2 = {"key": "value", 17.0: None, 0: True}
fingerprint1 = fingerprinting.hash_mapping(obj1, ignore_order=False)
fingerprint2 = fingerprinting.hash_mapping(obj2, ignore_order=False)
assert fingerprint1 != fingerprint2
def test_hash_unordered_mapping():
obj = {0: True, "key": "value", 17.0: None}
expected_hash = "4cnTFA4MEEzmBN4a04k6tA=="
fingerprint = fingerprinting.hash_mapping(obj, ignore_order=True)
assert fingerprint == expected_hash
def test_hash_mapping_where_order_doesnt_matter():
obj1 = {0: True, "key": "value", 17.0: None}
obj2 = {"key": "value", 17.0: None, 0: True}
fingerprint1 = fingerprinting.hash_mapping(obj1, ignore_order=True)
fingerprint2 = fingerprinting.hash_mapping(obj2, ignore_order=True)
assert fingerprint1 == fingerprint2
def test_hash_set():
obj = {0, True, "key", "value", 17.0, None}
expected_hash = "mswHhNBBYN5mv6i-LcEeVw=="
fingerprint = fingerprinting.hash_set(obj)
assert fingerprint == expected_hash
def test_hash_numpy():
# dtype is pinned explicitly so the literal digest is reproducible across
# platforms (the default integer dtype is platform-dependent).
array = np.array([[0, 1], [2, 3]], dtype=np.int64)
expected_hash = "Y1uek_eQTHejo2YtRvdWPQ=="
fingerprint = fingerprinting.hash_value(array)
assert fingerprint == expected_hash
def test_hash_numpy_different_shapes_differ():
"""Arrays with the same raw bytes but different shapes must hash differently."""
a = np.array([1, 2, 3, 4, 5, 6])
b = np.array([[1, 2, 3], [4, 5, 6]])
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
def test_hash_numpy_different_dtypes_differ():
"""Arrays with the same bit pattern but different dtypes must hash differently."""
a = np.array([1.0], dtype=np.float32)
b = np.array([1065353216], dtype=np.int32) # same 4 bytes as float32(1.0)
assert a.tobytes() == b.tobytes() # confirm same raw bytes
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
def test_hash_pandas_same_data_matches():
"""Identical pandas DataFrames must produce the same hash (determinism)."""
a = pd.DataFrame({"x": [1, 2], "y": [3, 4]})
b = pd.DataFrame({"x": [1, 2], "y": [3, 4]})
assert fingerprinting.hash_value(a) == fingerprinting.hash_value(b)
def test_hash_pandas_different_columns_differ():
"""pandas analog of test_hash_polars_different_columns_differ: identical
values under different column names must hash differently."""
a = pd.DataFrame({"region": ["East", "West"], "revenue": [100, 200]})
b = pd.DataFrame({"student": ["East", "West"], "height_cm": [100, 200]})
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
def test_hash_pandas_different_dtypes_differ():
"""pandas frames with identical values but different dtypes must hash differently."""
a = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) # int64
b = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]}) # float64
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
def test_hash_pandas_order_sensitive():
"""Reordering rows must change the fingerprint (order-sensitivity preserved)."""
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
assert fingerprinting.hash_value(df) != fingerprinting.hash_value(df.iloc[::-1])
def test_hash_polars_different_columns_differ():
"""DataFrames with identical values but different column names must hash differently."""
polars = pytest.importorskip("polars")
a = polars.DataFrame({"region": ["East", "West"], "revenue": [100, 200]})
b = polars.DataFrame({"student": ["East", "West"], "height_cm": [100, 200]})
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
def test_hash_polars_same_schema_same_data_matches():
"""Identical DataFrames must produce the same hash."""
polars = pytest.importorskip("polars")
a = polars.DataFrame({"x": [1, 2], "y": [3, 4]})
b = polars.DataFrame({"x": [1, 2], "y": [3, 4]})
assert fingerprinting.hash_value(a) == fingerprinting.hash_value(b)
def test_hash_polars_different_dtypes_differ():
"""polars frames with identical values but different dtypes must hash differently."""
polars = pytest.importorskip("polars")
a = polars.DataFrame({"a": [1, 2]}, schema={"a": polars.Int64})
b = polars.DataFrame({"a": [1, 2]}, schema={"a": polars.Float64})
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
def test_hash_cross_type_primitives_differ():
"""Values with the same string form but different types must hash differently.
Before type tagging, ``str(1) == str("1") == "1"`` collapsed int/str (and
likewise float/str and bytes/str) into identical fingerprints.
"""
fingerprints = {
fingerprinting.hash_value(1),
fingerprinting.hash_value("1"),
fingerprinting.hash_value(b"1"),
fingerprinting.hash_value(1.0),
fingerprinting.hash_value("1.0"),
}
assert len(fingerprints) == 5