diff --git a/bindings/python/python/opendal/__init__.pyi b/bindings/python/python/opendal/__init__.pyi index 0ebadaea2ee3..64fedbbf09c6 100644 --- a/bindings/python/python/opendal/__init__.pyi +++ b/bindings/python/python/opendal/__init__.pyi @@ -86,6 +86,7 @@ class AsyncOperator: @final class File: def read(self, size: Optional[int] = None) -> bytes: ... + def readline(self, size: Optional[int] = None) -> bytes: ... def write(self, bs: bytes) -> None: ... def seek(self, pos: int, whence: int = 0) -> int: ... def tell(self) -> int: ... diff --git a/bindings/python/src/file.rs b/bindings/python/src/file.rs index be7a461893e3..b23c6d2903cd 100644 --- a/bindings/python/src/file.rs +++ b/bindings/python/src/file.rs @@ -18,6 +18,7 @@ // Remove this `allow` after fixed. #![allow(clippy::unnecessary_fallible_conversions)] +use std::io::BufRead; use std::io::Read; use std::io::Seek; use std::io::SeekFrom; @@ -97,6 +98,51 @@ impl File { Buffer::new(buffer).into_bytes_ref(py) } + /// Read a single line from the file. + /// A newline character (`\n`) is left at the end of the string, and is only omitted on the last line of the file if the file doesn’t end in a newline. + /// If size is specified, at most size bytes will be read. + #[pyo3(signature = (size=None,))] + pub fn readline<'p>( + &'p mut self, + py: Python<'p>, + size: Option, + ) -> PyResult> { + let reader = match &mut self.0 { + FileState::Reader(r) => r, + FileState::Writer(_) => { + return Err(PyIOError::new_err( + "I/O operation failed for reading on write only file.", + )); + } + FileState::Closed => { + return Err(PyIOError::new_err( + "I/O operation failed for reading on closed file.", + )); + } + }; + + let buffer = match size { + None => { + let mut buffer = Vec::new(); + reader + .read_until(b'\n', &mut buffer) + .map_err(|err| PyIOError::new_err(err.to_string()))?; + buffer + } + Some(size) => { + let mut bs = vec![0; size]; + let mut reader = reader.take(size as u64); + let n = reader + .read_until(b'\n', &mut bs) + .map_err(|err| PyIOError::new_err(err.to_string()))?; + bs.truncate(n); + bs + } + }; + + Buffer::new(buffer).into_bytes_ref(py) + } + /// Read bytes into a pre-allocated, writable buffer pub fn readinto(&mut self, buffer: PyBuffer) -> PyResult { let reader = match &mut self.0 { diff --git a/bindings/python/tests/test_read.py b/bindings/python/tests/test_read.py index ddbd5f08a9d4..73ccd04f7d5c 100644 --- a/bindings/python/tests/test_read.py +++ b/bindings/python/tests/test_read.py @@ -16,7 +16,8 @@ # under the License. import os -from random import randint +import io +from random import randint, choices from uuid import uuid4 import pytest @@ -73,6 +74,33 @@ def test_sync_reader(service_name, operator, async_operator): operator.delete(filename) +@pytest.mark.need_capability("read", "write", "delete") +def test_sync_reader_readline(service_name, operator, async_operator): + size = randint(1, 1024) + lines = randint(1, min(100, size)) + filename = f"random_file_{str(uuid4())}" + content = bytearray(os.urandom(size)) + + for idx in choices(range(0, size), k=lines): + content[idx] = ord("\n") + operator.write(filename, content) + + line_contents = io.BytesIO(content).readlines() + i = 0 + + with operator.open(filename, "rb") as reader: + assert reader.readable() + assert not reader.writable() + assert not reader.closed + + while (read_content := reader.readline()) != b"": + assert read_content is not None + assert read_content == line_contents[i] + i += 1 + + operator.delete(filename) + + @pytest.mark.asyncio @pytest.mark.need_capability("read", "write", "delete") async def test_async_read(service_name, operator, async_operator):