diff --git a/src/compact_encoding/__init__.py b/src/compact_encoding/__init__.py index 2f7b1d4..a06aeb6 100644 --- a/src/compact_encoding/__init__.py +++ b/src/compact_encoding/__init__.py @@ -5,6 +5,7 @@ from .codec import Codec, CompactError, OutOfBounds, decode, encode from .fixed import fixed, fixed32, fixed64 from .float import float32, float64 +from .frame import frame from .integer import ( int24, int40, @@ -39,6 +40,7 @@ "fixed64", "float32", "float64", + "frame", "int", "int24", "int40", diff --git a/src/compact_encoding/frame.py b/src/compact_encoding/frame.py new file mode 100644 index 0000000..dc61bc0 --- /dev/null +++ b/src/compact_encoding/frame.py @@ -0,0 +1,31 @@ +from . import integer +from .state import State + + +class _Frame: + def __init__(self, codec): + self._codec = codec + + def preencode(self, state, m): + end = state.end + self._codec.preencode(state, m) + integer.uint.preencode(state, state.end - end) + + def encode(self, state, m): + dummy = State() + self._codec.preencode(dummy, m) + integer.uint.encode(state, dummy.end) + self._codec.encode(state, m) + + def decode(self, state): + end = state.end + length = integer.uint.decode(state) + state.end = state.start + length + m = self._codec.decode(state) + state.start = state.end + state.end = end + return m + + +def frame(codec): + return _Frame(codec) diff --git a/tests/test_frame.py b/tests/test_frame.py new file mode 100644 index 0000000..6f0402f --- /dev/null +++ b/tests/test_frame.py @@ -0,0 +1,59 @@ +import pytest + +import compact_encoding as cenc +from compact_encoding.codec import OutOfBounds + +# frame(uint): uint(len) prefix + inner uint bytes. +# 5 -> inner uint(5) = 05 (1 byte) -> len 01 -> 0105 +# 0 -> inner uint(0) = 00 (1 byte) -> len 01 -> 0100 +# 300 -> inner uint(300) = fd2c01 (3 bytes) -> len 03 -> 03fd2c01 +FRAME_UINT_CASES = [ + (5, "0105"), + (0, "0100"), + (300, "03fd2c01"), +] + + +@pytest.mark.parametrize("value,hexbytes", FRAME_UINT_CASES) +def test_frame_uint_encode(value, hexbytes): + assert cenc.encode(cenc.frame(cenc.uint), value).hex() == hexbytes + + +@pytest.mark.parametrize("value,hexbytes", FRAME_UINT_CASES) +def test_frame_uint_decode(value, hexbytes): + assert cenc.decode(cenc.frame(cenc.uint), bytes.fromhex(hexbytes)) == value + + +def test_frame_buffer_matches_length_prefix(): + # A framed buffer is a length prefix around c.buffer's own bytes: the + # frame length equals the full inner encoding (uint-len + raw bytes). + payload = b"\x01\x02\x03" + inner = cenc.encode(cenc.buffer, payload) # 03010203 (4 bytes) + framed = cenc.encode(cenc.frame(cenc.buffer), payload) + assert framed.hex() == "04" + inner.hex() # len 04 + 03010203 + assert cenc.decode(cenc.frame(cenc.buffer), framed) == payload + + +def test_frame_restores_cursor_for_following_field(): + # Encode a framed value then a plain uint; both must decode back in order, + # proving decode restores state.end and leaves state.start after the frame. + from compact_encoding.state import State + + fr = cenc.frame(cenc.uint) + state = State() + fr.preencode(state, 300) + cenc.uint.preencode(state, 42) + state.allocate() + fr.encode(state, 300) + cenc.uint.encode(state, 42) + + verify = State(bytes(state.buffer)) + assert fr.decode(verify) == 300 + assert cenc.uint.decode(verify) == 42 + + +def test_frame_decode_truncated_raises(): + # Inner uint's continuation byte (0xfd) demands 2 more bytes that + # aren't present; verified this also raises against the JS reference. + with pytest.raises(OutOfBounds): + cenc.decode(cenc.frame(cenc.uint), bytes.fromhex("01fd")) diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index b2743a2..9402130 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -31,6 +31,7 @@ (cenc.fixed32, bytes(range(32))), (cenc.fixed64, bytes(range(64))), (cenc.json, {"a": [1, 2], "b": None}), + (cenc.frame(cenc.uint), 42), ]