This is an automated email from the ASF dual-hosted git repository.

pitrou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new 84027295a1 GH-49305: [Python] Expose RecordBatchFileReader.count_rows 
(#50646)
84027295a1 is described below

commit 84027295a1f5dd3a100ae4f5bc41ad68a45ff388
Author: Guja <[email protected]>
AuthorDate: Thu Jul 30 11:59:47 2026 +0200

    GH-49305: [Python] Expose RecordBatchFileReader.count_rows (#50646)
    
    ### Rationale for this change
    
    Resolves [49305](https://github.com/apache/arrow/issues/49305)
    
    `RecordBatchFileReader::CountRows` has existed in Arrow C++ 
(`cpp/src/arrow/ipc/reader.h`) but
    was never bound in Python, so the only way to get the total number of rows 
of an IPC file was:
    
    ```python
    num_rows = sum(reader.get_batch(i).num_rows for i in 
range(reader.num_record_batches))
    ```
    
    ### This findings are done in original opened Issue #49305
    
    That deserializes every record batch just to read its length, which is 
wasteful and becomes
    expensive on remote filesystems.
    
    ### What changes are included in this PR?
    
    Adds `RecordBatchFileReader.count_rows()`:
    
    ```python
    with pa.ipc.open_file(source) as reader:
        reader.count_rows()
    ```
    
    Three changes:
    
    * `python/pyarrow/includes/libarrow.pxd`: declare `CResult[int64_t] 
CountRows()` on
      `CRecordBatchFileReader`, which was the missing piece.
    * `python/pyarrow/ipc.pxi`: add `count_rows()` to `_RecordBatchFileReader`, 
released GIL around
      the call, with the same closed reader guard used by the existing `stats` 
property
    * `python/pyarrow/tests/test_ipc.py`: tests.
    
    On the naming: `count_rows()` follows the C++ method and is consistent with 
the existing
    `count_rows()` on `Dataset`, `Scanner` and `Fragment`.
    
    To be precise about the benefit, since the issue describes it as reading 
the count from the
    metadata: the C++ implementation still walks every block, but reads only 
each record batch's
    flatbuffer message header to pick up its length, and never touches the data 
buffers. So this is
    a reduction in bytes read rather than in the number of reads, and the gain 
shows up on remote
    filesystems and on files with large batches rather than in a local in 
memory benchmark.
    
    This is only added to the file reader. The stream reader has no footer and 
cannot count rows
    without consuming the stream.
    
    ### Are these changes tested?
    
    Yes, two tests in `python/pyarrow/tests/test_ipc.py`:
    
    * `test_file_count_rows`: count matches the sum of the written batch 
lengths, and counting does
      not consume the reader (count, `read_all()`, count again).
    * `test_file_count_rows_no_batches`: a file with a schema but no batches 
counts 0.
    
    Locally `test_ipc.py` passes (72 tests) and `test_feather.py` passes (83 
passed, 8 skipped,
    1 xfailed), the latter because the feather reader sits on the same file 
reader. The docstring
    example was run and produces the output shown.
    
    ### Are there any user-facing changes?
    
    Yes, a new public method `RecordBatchFileReader.count_rows()`. No existing 
behaviour changes.
    
    ### AI usage disclosure
    
    I used Claude Code to locate the unbound C++ method and the place where the 
declaration was
    missing, and to draft the binding, the docstring and the tests. I reviewed 
the result, rebuilt
    PyArrow locally, ran the test suites quoted above, and checked the C++ 
implementation of
    `CountRows` myself to confirm what it actually does before describing the 
benefit here.
    * GitHub Issue: #49305
    
    Authored-by: Guja <[email protected]>
    Signed-off-by: Antoine Pitrou <[email protected]>
---
 python/pyarrow/includes/libarrow.pxd |  2 ++
 python/pyarrow/ipc.pxi               | 30 ++++++++++++++++++++++++++++++
 python/pyarrow/tests/test_ipc.py     | 24 ++++++++++++++++++++++++
 3 files changed, 56 insertions(+)

diff --git a/python/pyarrow/includes/libarrow.pxd 
b/python/pyarrow/includes/libarrow.pxd
index e57c6d0d92..efc9602a3a 100644
--- a/python/pyarrow/includes/libarrow.pxd
+++ b/python/pyarrow/includes/libarrow.pxd
@@ -2011,6 +2011,8 @@ cdef extern from "arrow/ipc/api.h" namespace "arrow::ipc" 
nogil:
 
         CResult[CRecordBatchWithMetadata] 
ReadRecordBatchWithCustomMetadata(int i)
 
+        CResult[int64_t] CountRows()
+
         CIpcReadStats stats()
 
         shared_ptr[const CKeyValueMetadata] metadata()
diff --git a/python/pyarrow/ipc.pxi b/python/pyarrow/ipc.pxi
index 6477579af2..2c0d9591c1 100644
--- a/python/pyarrow/ipc.pxi
+++ b/python/pyarrow/ipc.pxi
@@ -1192,6 +1192,36 @@ cdef class _RecordBatchFileReader(_Weakrefable):
         """
         return self.reader.get().num_record_batches()
 
+    def count_rows(self):
+        """
+        The total number of rows in the IPC file.
+
+        This reads the metadata of each record batch in the file, without
+        deserializing the record batches themselves.
+
+        Returns
+        -------
+        count : int
+
+        Examples
+        --------
+        >>> import pyarrow as pa
+        >>> schema = pa.schema([('a', pa.int64())])
+        >>> sink = pa.BufferOutputStream()
+        >>> with pa.ipc.new_file(sink, schema) as writer:
+        ...     for i in range(3):
+        ...         writer.write_batch(pa.record_batch([[1, 2]], 
schema=schema))
+        >>> with pa.ipc.open_file(sink.getvalue()) as reader:
+        ...     reader.count_rows()
+        6
+        """
+        cdef int64_t nrows
+
+        with nogil:
+            nrows = GetResultValue(self.reader.get().CountRows())
+
+        return nrows
+
     def get_batch(self, int i):
         """
         Read the record batch with the given index.
diff --git a/python/pyarrow/tests/test_ipc.py b/python/pyarrow/tests/test_ipc.py
index 6813ed7772..121617371c 100644
--- a/python/pyarrow/tests/test_ipc.py
+++ b/python/pyarrow/tests/test_ipc.py
@@ -189,6 +189,30 @@ def test_file_read_all(sink_factory):
     assert result.equals(expected)
 
 
+def test_file_count_rows(file_fixture):
+    batches = file_fixture.write_batches()
+    file_contents = pa.BufferReader(file_fixture.get_source())
+
+    reader = pa.ipc.open_file(file_contents)
+
+    expected = sum(batch.num_rows for batch in batches)
+    assert reader.count_rows() == expected
+    # counting the rows does not consume the reader
+    assert reader.read_all().num_rows == expected
+    assert reader.count_rows() == expected
+
+
+def test_file_count_rows_no_batches():
+    schema = pa.schema([('a', pa.int64())])
+    sink = pa.BufferOutputStream()
+    with pa.ipc.new_file(sink, schema):
+        pass
+
+    reader = pa.ipc.open_file(sink.getvalue())
+    assert reader.num_record_batches == 0
+    assert reader.count_rows() == 0
+
+
 def test_open_file_from_buffer(file_fixture):
     # ARROW-2859; APIs accept the buffer protocol
     file_fixture.write_batches()

Reply via email to