From e2dcf2d6b05103f5b4f7f8c205580de8f1fa007f Mon Sep 17 00:00:00 2001 From: acul71 Date: Fri, 2 Oct 2026 04:37:43 -0400 Subject: [PATCH] Add cast and mh_from_bytes for go-multihash parity. Co-authored-by: Cursor --- multihash/__init__.py | 8 ++++++ multihash/multihash.py | 47 ++++++++++++++++++++++++++++++++++++ newsfragments/56.feature.rst | 1 + tests/test_new_features.py | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 newsfragments/56.feature.rst diff --git a/multihash/__init__.py b/multihash/__init__.py index bb186de..6b5b078 100644 --- a/multihash/__init__.py +++ b/multihash/__init__.py @@ -13,8 +13,11 @@ ) from .funcs import Func, FuncReg, IdentityHash, ShakeHash from .multihash import ( + Cast, + MHFromBytes, Multihash, MultihashSet, + cast, coerce_code, decode, digest, @@ -26,6 +29,7 @@ is_app_code, is_valid, is_valid_code, + mh_from_bytes, sum, sum_stream, to_b58_string, @@ -33,17 +37,20 @@ ) __all__ = [ + "Cast", "Func", "FuncReg", "HashComputationError", "IdentityHash", "InvalidMultihashError", + "MHFromBytes", "Multihash", "MultihashError", "MultihashSet", "ShakeHash", "TruncationError", "UnsupportedCodeError", + "cast", "coerce_code", "decode", "digest", @@ -55,6 +62,7 @@ "is_app_code", "is_valid", "is_valid_code", + "mh_from_bytes", "sum", "sum_stream", "to_b58_string", diff --git a/multihash/multihash.py b/multihash/multihash.py index d07e39c..f067bc0 100644 --- a/multihash/multihash.py +++ b/multihash/multihash.py @@ -825,6 +825,53 @@ def decode(multihash): return Multihash(code=code, name=constants.CODE_HASHES.get(code, code), length=length, digest=buf) +def cast(buf: bytes) -> Multihash: + """Validate ``buf`` as a multihash and return a :class:`Multihash`. + + This is the Python equivalent of go-multihash ``Cast``: the entire buffer + must be a valid multihash (no trailing data). + + Args: + buf: Raw multihash bytes + + Returns: + Multihash: Decoded multihash + + Raises: + TypeError: If ``buf`` is not bytes + ValueError: If ``buf`` is not a valid multihash + """ + return decode(buf) + + +Cast = cast + + +def mh_from_bytes(buf: bytes) -> tuple[int, Multihash]: + """Read a multihash from the start of ``buf``, allowing trailing data. + + This is the Python equivalent of go-multihash ``MHFromBytes``. + + Args: + buf: Buffer that begins with a multihash (may contain trailing bytes) + + Returns: + Tuple of ``(bytes_consumed, Multihash)`` + + Raises: + TypeError: If ``buf`` is not bytes + ValueError: If the leading bytes are not a valid multihash + """ + if not isinstance(buf, bytes): + raise TypeError(f"buf should be bytes, not {type(buf)}") + stream = BytesIO(buf) + mh = Multihash.read(stream) + return stream.tell(), mh + + +MHFromBytes = mh_from_bytes + + def encode(digest, code, length=None): """ Encode a hash digest along with the specified function code diff --git a/newsfragments/56.feature.rst b/newsfragments/56.feature.rst new file mode 100644 index 0000000..5b68aa8 --- /dev/null +++ b/newsfragments/56.feature.rst @@ -0,0 +1 @@ +Add :func:`~multihash.cast` and :func:`~multihash.mh_from_bytes` for go-multihash ``Cast`` / ``MHFromBytes`` parity. diff --git a/tests/test_new_features.py b/tests/test_new_features.py index 2b081d8..1b6da59 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -16,9 +16,11 @@ MultihashSet, ShakeHash, TruncationError, + cast, decode, digest, from_json, + mh_from_bytes, sum, sum_stream, ) @@ -919,3 +921,43 @@ def test_read_write_app_code(self): finally: # Clean up FuncReg.unregister(app_code) + + +class TestCastAndMhFromBytesTestCase: + """Tests for cast() and mh_from_bytes().""" + + def test_cast_valid(self): + mh = sum(b"hello", Func.sha2_256) + encoded = mh.encode() + casted = cast(encoded) + assert casted == decode(encoded) + assert casted.digest == mh.digest + + def test_cast_rejects_trailing_data(self): + mh = sum(b"hello", Func.sha2_256) + with pytest.raises(ValueError): + cast(mh.encode() + b"extra") + + def test_mh_from_bytes_with_trailing(self): + mh = sum(b"hello", Func.sha2_256) + encoded = mh.encode() + buf = encoded + b"trailing-bytes" + consumed, parsed = mh_from_bytes(buf) + assert consumed == len(encoded) + assert parsed.digest == mh.digest + assert parsed.code == mh.code + + def test_mh_from_bytes_exact(self): + mh = sum(b"hello", Func.sha2_256) + encoded = mh.encode() + consumed, parsed = mh_from_bytes(encoded) + assert consumed == len(encoded) + assert parsed == decode(encoded) + + def test_go_aliases(self): + from multihash import Cast, MHFromBytes + + mh = sum(b"hello", Func.sha2_256) + encoded = mh.encode() + assert Cast(encoded) == cast(encoded) + assert MHFromBytes(encoded) == mh_from_bytes(encoded)