Skip to content

Commit ae3f006

Browse files
dkropachevclaude
andcommitted
Add tests and fix BytesReader to match BytesIO.read() semantics
BytesReader.read(n) now returns partial data at end-of-buffer instead of raising EOFError, matching BytesIO behavior. Added 11 unit tests for BytesReader and 6 tests verifying _ConnectionIOBuffer reset position semantics under both checksumming and non-checksumming paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e3e5812 commit ae3f006

3 files changed

Lines changed: 144 additions & 7 deletions

File tree

cassandra/protocol.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@ class InternalError(Exception):
5757
class BytesReader:
5858
"""
5959
Lightweight reader for bytes data without BytesIO overhead.
60-
Provides the same read() interface but operates directly on a
61-
bytes or memoryview object, avoiding internal buffer copies.
60+
Provides the same read() interface as BytesIO but operates directly
61+
on a bytes or memoryview object, avoiding internal buffer copies.
62+
63+
read(n) behaves like BytesIO.read(n): returns up to n bytes and
64+
returns fewer bytes (or empty bytes) when the end of data is reached.
6265
"""
6366
__slots__ = ('_data', '_pos', '_size')
6467

@@ -72,9 +75,7 @@ def read(self, n=-1):
7275
result = self._data[self._pos:]
7376
self._pos = self._size
7477
else:
75-
end = self._pos + n
76-
if end > self._size:
77-
raise EOFError("Cannot read past the end of the buffer")
78+
end = min(self._pos + n, self._size)
7879
result = self._data[self._pos:end]
7980
self._pos = end
8081
# Return bytes to maintain compatibility with unpack functions

tests/unit/test_connection.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
from cassandra.cluster import Cluster
2323
from cassandra.connection import (Connection, HEADER_DIRECTION_TO_CLIENT, ProtocolError,
2424
locally_supported_compressions, ConnectionHeartbeat, _Frame, Timer, TimerManager,
25-
ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator)
25+
ConnectionException, ConnectionShutdown, DefaultEndPoint, ShardAwarePortGenerator,
26+
_ConnectionIOBuffer)
2627
from cassandra.marshal import uint8_pack, uint32_pack, int32_pack
2728
from cassandra.protocol import (write_stringmultimap, write_int, write_string,
2829
SupportedMessage, ProtocolHandler)
@@ -571,3 +572,80 @@ def test_generate_is_repeatable_with_same_mock(self, mock_randrange):
571572
second_run = list(itertools.islice(gen.generate(0, 2), 5))
572573

573574
assert first_run == second_run
575+
576+
577+
class TestConnectionIOBufferReset(unittest.TestCase):
578+
"""Verify _reset_buffer and reset_cql_frame_buffer position semantics."""
579+
580+
def test_reset_buffer_discards_consumed_data(self):
581+
buf = BytesIO(b'\x01\x02\x03\x04\x05')
582+
buf.seek(0)
583+
# Consume first 3 bytes
584+
assert buf.read(3) == b'\x01\x02\x03'
585+
new_buf = _ConnectionIOBuffer._reset_buffer(buf)
586+
# New buffer should contain only unconsumed data
587+
new_buf.seek(0)
588+
assert new_buf.read() == b'\x04\x05'
589+
590+
def test_reset_buffer_position_at_end(self):
591+
buf = BytesIO(b'\x01\x02\x03')
592+
buf.seek(0)
593+
buf.read(1)
594+
new_buf = _ConnectionIOBuffer._reset_buffer(buf)
595+
# Position should be at end (ready for appending)
596+
assert new_buf.tell() == 2
597+
598+
def test_reset_buffer_fully_consumed(self):
599+
buf = BytesIO(b'\x01\x02')
600+
buf.seek(0)
601+
buf.read(2)
602+
new_buf = _ConnectionIOBuffer._reset_buffer(buf)
603+
new_buf.seek(0)
604+
assert new_buf.read() == b''
605+
606+
def test_reset_buffer_nothing_consumed(self):
607+
buf = BytesIO(b'\x01\x02\x03')
608+
buf.seek(0)
609+
new_buf = _ConnectionIOBuffer._reset_buffer(buf)
610+
new_buf.seek(0)
611+
assert new_buf.read() == b'\x01\x02\x03'
612+
613+
@staticmethod
614+
def _make_iobuf(checksumming=False):
615+
conn = Mock()
616+
conn._is_checksumming_enabled = checksumming
617+
iobuf = _ConnectionIOBuffer(conn)
618+
# Keep a strong reference so the weakref.proxy inside iobuf stays valid
619+
iobuf._conn_ref = conn
620+
if checksumming:
621+
iobuf.set_checksumming_buffer()
622+
return iobuf
623+
624+
def test_reset_cql_frame_buffer_checksumming_uses_tell_position(self):
625+
"""
626+
When checksumming is enabled, reset_cql_frame_buffer delegates to
627+
_reset_buffer which relies on tell() to determine consumed data.
628+
Verify that seeking to an arbitrary position before reset correctly
629+
preserves only the unconsumed tail.
630+
"""
631+
iobuf = self._make_iobuf(checksumming=True)
632+
# Write some data into the cql_frame_buffer
633+
iobuf.cql_frame_buffer.write(b'\xAA\xBB\xCC\xDD\xEE')
634+
# Seek to position 3, simulating that first 3 bytes were consumed
635+
iobuf.cql_frame_buffer.seek(3)
636+
iobuf.reset_cql_frame_buffer()
637+
# After reset, only the unconsumed tail should remain
638+
iobuf.cql_frame_buffer.seek(0)
639+
assert iobuf.cql_frame_buffer.read() == b'\xDD\xEE'
640+
641+
def test_reset_cql_frame_buffer_no_checksumming_resets_io_buffer(self):
642+
"""
643+
Without checksumming, reset_cql_frame_buffer delegates to
644+
reset_io_buffer (since cql_frame_buffer IS the io_buffer).
645+
"""
646+
iobuf = self._make_iobuf(checksumming=False)
647+
iobuf.io_buffer.write(b'\x01\x02\x03\x04')
648+
iobuf.io_buffer.seek(2)
649+
iobuf.reset_cql_frame_buffer()
650+
iobuf.io_buffer.seek(0)
651+
assert iobuf.io_buffer.read() == b'\x03\x04'

tests/unit/test_protocol.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
PrepareMessage, QueryMessage, ExecuteMessage, UnsupportedOperation,
2222
_PAGING_OPTIONS_FLAG, _WITH_SERIAL_CONSISTENCY_FLAG,
2323
_PAGE_SIZE_FLAG, _WITH_PAGING_STATE_FLAG,
24-
BatchMessage
24+
BatchMessage, BytesReader
2525
)
2626
from cassandra.query import BatchType
2727
from cassandra.marshal import uint32_unpack
@@ -189,3 +189,61 @@ def test_batch_message_with_keyspace(self):
189189
(b'\x00\x03',),
190190
(b'\x00\x00\x00\x80',), (b'\x00\x02',), (b'ks',))
191191
)
192+
193+
194+
class BytesReaderTest(unittest.TestCase):
195+
196+
def test_read_exact_n_bytes(self):
197+
reader = BytesReader(b'\x01\x02\x03\x04\x05')
198+
assert reader.read(3) == b'\x01\x02\x03'
199+
200+
def test_read_all_with_negative(self):
201+
reader = BytesReader(b'hello')
202+
assert reader.read(-1) == b'hello'
203+
204+
def test_read_all_default(self):
205+
reader = BytesReader(b'hello')
206+
reader.read(2)
207+
assert reader.read(-1) == b'llo'
208+
209+
def test_sequential_reads(self):
210+
reader = BytesReader(b'\x01\x02\x03\x04')
211+
assert reader.read(2) == b'\x01\x02'
212+
assert reader.read(2) == b'\x03\x04'
213+
214+
def test_read_past_end_returns_partial(self):
215+
reader = BytesReader(b'\x01\x02')
216+
assert reader.read(3) == b'\x01\x02'
217+
218+
def test_read_past_end_after_partial_consume(self):
219+
reader = BytesReader(b'\x01\x02\x03')
220+
reader.read(2)
221+
assert reader.read(2) == b'\x03'
222+
223+
def test_read_at_end_returns_empty(self):
224+
reader = BytesReader(b'\x01\x02')
225+
reader.read(2)
226+
assert reader.read(1) == b''
227+
228+
def test_read_zero_bytes(self):
229+
reader = BytesReader(b'abc')
230+
assert reader.read(0) == b''
231+
232+
def test_memoryview_input_returns_bytes(self):
233+
data = b'\x01\x02\x03\x04'
234+
reader = BytesReader(memoryview(data))
235+
result = reader.read(2)
236+
assert isinstance(result, bytes)
237+
assert result == b'\x01\x02'
238+
239+
def test_memoryview_read_all_returns_bytes(self):
240+
data = b'\x01\x02\x03'
241+
reader = BytesReader(memoryview(data))
242+
result = reader.read(-1)
243+
assert isinstance(result, bytes)
244+
assert result == b'\x01\x02\x03'
245+
246+
def test_empty_input(self):
247+
reader = BytesReader(b'')
248+
assert reader.read(-1) == b''
249+
assert reader.read(1) == b''

0 commit comments

Comments
 (0)