diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index aa064ae..5fe7adc 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -52,7 +52,7 @@ jobs: - name: Test with pytest run: | # Generates an XML report required by Codecov - pytest --cov=keyedstablehash --cov-report=xml --cov-report=term + pytest --cov=src/keyedstablehash --cov-report=term --cov-report=xml # 6. Upload Coverage: Send report to Codecov.io - name: Upload coverage to Codecov diff --git a/tests/test_core.py b/tests/test_core.py index 7c1c2b3..a32ee57 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,16 +1,20 @@ -import sys import os import struct -from unittest.mock import patch +import sys +from unittest.mock import patch, MagicMock sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) import pytest +import src.keyedstablehash.canonical as canonical_module from src.keyedstablehash.canonical import canonicalize_to_bytes from src.keyedstablehash.siphash import siphash24 from src.keyedstablehash.stable import stable_keyed_hash -import src.keyedstablehash.canonical as canonical_module + +# ------------------------------------------------------------------------- +# Test Vectors & Reference Checks +# ------------------------------------------------------------------------- SIPHASH_VECTORS = { 0: "310e0edd47db6f72", @@ -38,6 +42,38 @@ def test_siphash_vectors_match_reference(): assert hasher.hexdigest() == expected_hex +# ------------------------------------------------------------------------- +# Stable Hash & KeyedStableHash Class Tests +# ------------------------------------------------------------------------- + + +def test_keyed_stable_hash_output_formats(): + """Test hexdigest() and intdigest() methods of KeyedStableHash.""" + key = b"\x00" * 16 + payload = "test_data" + result = stable_keyed_hash(payload, key=key) + + # 1. Test digest() + assert isinstance(result.digest(), bytes) + assert len(result.digest()) == 8 + + # 2. Test hexdigest() + hex_val = result.hexdigest() + assert isinstance(hex_val, str) + assert hex_val == result.digest().hex() + + # 3. Test intdigest() + int_val = result.intdigest() + assert isinstance(int_val, int) + expected_int = int.from_bytes(result.digest(), byteorder="little", signed=False) + assert int_val == expected_int + + +def test_stable_keyed_hash_algo_error(): + with pytest.raises(ValueError, match="Unsupported algorithm"): + stable_keyed_hash(123, key=b"0" * 16, algo="unknown_algo") + + def test_stable_hash_dict_is_order_independent(): key = b"\x01" * 16 first = stable_keyed_hash({"b": [2, 3], "a": 1}, key=key) @@ -52,6 +88,56 @@ def test_stable_hash_respects_key(): assert digest_a != digest_b +# ------------------------------------------------------------------------- +# SipHash Implementation Tests +# ------------------------------------------------------------------------- + + +def test_siphash_intdigest(): + key = b"\x00" * 16 + hasher = siphash24(key) + hasher.update(b"test") + int_val = hasher.intdigest() + digest_bytes = hasher.digest() + expected_int = struct.unpack(" 10 def test_feed_canonical_dict_order(): d1 = {"x": 1, "y": 2} d2 = {"y": 2, "x": 1} - b1 = canonicalize_to_bytes(d1) - b2 = canonicalize_to_bytes(d2) - assert b1 == b2 + assert canonicalize_to_bytes(d1) == canonicalize_to_bytes(d2) def test_feed_canonical_list_vs_tuple(): @@ -124,99 +186,213 @@ def test_feed_canonical_set_order(): s1 = {1, 2, 3} s2 = {3, 2, 1} assert canonicalize_to_bytes(s1) == canonicalize_to_bytes(s2) - assert canonicalize_to_bytes(s1).startswith(b"E") -def test_canonical_frozenset(): - fs = frozenset([3, 2, 1]) - s = {1, 2, 3} - # Frozenset and Set should produce the same content encoding if logic allows, - # or at least be supported. In canonical.py both use _handle_set. - assert canonicalize_to_bytes(fs) == canonicalize_to_bytes(s) +def test_normalize_scalar_numpy_generic(): + try: + import numpy as np + + val = np.int64(10) + assert canonicalize_to_bytes(val) == canonicalize_to_bytes(10) + except ImportError: + pytest.skip("Numpy not installed") + + +def test_numpy_mock_normalization(): + class FakeGeneric: + def item(self): + return 99 + with patch.object(canonical_module, "_NUMPY_GENERIC", (FakeGeneric,)): + fake_val = FakeGeneric() + assert canonicalize_to_bytes(fake_val) == canonicalize_to_bytes(99) -def test_canonical_primitives_full_coverage(): - # None + +def test_handle_object_with_dict(): + class MyClass: + def __init__(self, a, b): + self.a = a + self.b = b + + obj = MyClass(1, "test") + type_name = f"{obj.__class__.__module__}.{obj.__class__.__qualname__}".encode( + "utf-8" + ) + canonical_vars = canonicalize_to_bytes({"a": 1, "b": "test"}) + expected = b"O" + len(type_name).to_bytes(8, "little") + type_name + canonical_vars + assert canonicalize_to_bytes(obj) == expected + + +def test_canonical_none(): assert canonicalize_to_bytes(None) == b"N" - # Bool + +def test_canonical_bool(): assert canonicalize_to_bytes(True) == b"B\x01" assert canonicalize_to_bytes(False) == b"B\x00" - # Float - f_val = 1.234 - encoded_f = canonicalize_to_bytes(f_val) - assert encoded_f.startswith(b"F") - assert struct.pack("