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

rok 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 85d0d9d353 GH-49644: [Python] Support converting list of 
multi-dimensional arrays to FixedShapeTensor (#50203)
85d0d9d353 is described below

commit 85d0d9d353790cafddd7f13b6ebf9ca8eb8b3095
Author: Samuel Aboderin <[email protected]>
AuthorDate: Mon Jun 29 14:38:44 2026 +0100

    GH-49644: [Python] Support converting list of multi-dimensional arrays to 
FixedShapeTensor (#50203)
    
    ### Rationale for this change
    
    Constructing a fixed-shape-tensor array from a list of individual ndarrays 
only
    worked when each element was 1-D; ≥2-D elements failed with
    `ArrowInvalid: Can only convert 1-dimensional array values`. The only 
workaround
    was stacking the list into a single ndarray and using
    `FixedShapeTensorArray.from_numpy_ndarray`.
    
    ### What changes are included in this PR?
    
    The C++ list converter `PyListConverter::AppendNdarray` now accepts
    multi-dimensional ndarray elements for **fixed-size lists** (the storage of 
a
    fixed-shape tensor) by flattening them in C order. The fixed-size-list 
builder
    still validates that the flattened length matches the list width, so wrong 
sizes
    error cleanly. Variable-sized lists remain restricted to 1-D values to avoid
    ambiguity. As a side benefit, plain `fixed_size_list` also accepts
    multi-dimensional ndarray elements now.
    
    ### Are these changes tested?
    
    Yes:
    - `test_tensor_array_from_list_of_ndarrays` — construction from 2-D and 3-D
      ndarrays, null handling, storage parity with `from_numpy_ndarray`, and the
      size-mismatch error, across `int8`/`int64`/`float32`.
    - `test_fixed_size_list_from_multidim_ndarray` — plain `fixed_size_list` 
from
      multi-dim arrays, plus a check that variable-sized lists still reject 2-D.
    
    ### Are there any user-facing changes?
    
    Yes — `pa.array([multi-dim ndarrays], type=fixed_shape_tensor(...))` (and 
the
    same for `fixed_size_list`) now works instead of raising. Existing 1-D 
behavior
    and variable-sized-list behavior are unchanged.
    
    Scoped to construction only; the reverse `to_numpy` shape-preservation also
    raised in the issue is intentionally left as a separate follow-up.
    * GitHub Issue: #49644
    
    Lead-authored-by: Samuel Aboderin <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    Signed-off-by: Rok Mihevc <[email protected]>
---
 python/pyarrow/array.pxi                           | 41 ++++++++++++++
 python/pyarrow/src/arrow/python/python_to_arrow.cc | 23 +++++++-
 python/pyarrow/tests/test_array.py                 | 24 ++++++++
 python/pyarrow/tests/test_extension_type.py        | 66 ++++++++++++++++++++++
 4 files changed, 151 insertions(+), 3 deletions(-)

diff --git a/python/pyarrow/array.pxi b/python/pyarrow/array.pxi
index 81c7be61bb..5fc74969ab 100644
--- a/python/pyarrow/array.pxi
+++ b/python/pyarrow/array.pxi
@@ -266,6 +266,23 @@ def array(object obj, type=None, mask=None, size=None, 
from_pandas=None,
     if type is not None and type.id == _Type_EXTENSION:
         extension_type = type
         type = type.storage_type
+        # GH-49644: when building a fixed_shape_tensor from a sequence of 
arrays,
+        # the converter only sees the flat storage type, so validate the
+        # tensor-specific constraints here where the type is still known.
+        if (isinstance(extension_type, FixedShapeTensorType)
+                and isinstance(obj, (list, tuple))):
+            if extension_type.permutation is not None:
+                raise NotImplementedError(
+                    "Converting a sequence of arrays to a fixed_shape_tensor "
+                    "with a permutation is not supported")
+            expected_shape = tuple(extension_type.shape)
+            for element in obj:
+                shape = getattr(element, "shape", None)
+                if (shape is not None and len(shape) >= 2
+                        and tuple(shape) != expected_shape):
+                    raise ValueError(
+                        f"Cannot convert array of shape {tuple(shape)} to a "
+                        f"fixed_shape_tensor of shape {expected_shape}")
 
     if from_pandas is None:
         c_from_pandas = False
@@ -4698,6 +4715,30 @@ cdef class FixedShapeTensorArray(ExtensionArray):
         400
       ]
     ]
+
+    Create an extension array from a list of multi-dimensional NumPy arrays.
+    Each element is flattened in row-major (C) order, and its shape must match
+    the tensor shape.
+
+    >>> import numpy as np
+    >>> pa.array([np.array([[1, 2], [3, 4]], dtype=np.int32),
+    ...           np.array([[10, 20], [30, 40]], dtype=np.int32)],
+    ...          type=tensor_type)
+    <pyarrow.lib.FixedShapeTensorArray object at ...>
+    [
+      [
+        1,
+        2,
+        3,
+        4
+      ],
+      [
+        10,
+        20,
+        30,
+        40
+      ]
+    ]
     """
 
     def to_numpy_ndarray(self):
diff --git a/python/pyarrow/src/arrow/python/python_to_arrow.cc 
b/python/pyarrow/src/arrow/python/python_to_arrow.cc
index e7ce54abcd..47290a7e7f 100644
--- a/python/pyarrow/src/arrow/python/python_to_arrow.cc
+++ b/python/pyarrow/src/arrow/python/python_to_arrow.cc
@@ -908,13 +908,30 @@ class PyListConverter : public ListConverter<T, 
PyConverter, PyConverterTrait> {
 
   Status AppendNdarray(PyObject* value) {
     PyArrayObject* ndarray = reinterpret_cast<PyArrayObject*>(value);
-    if (PyArray_NDIM(ndarray) != 1) {
-      return Status::Invalid("Can only convert 1-dimensional array values");
-    }
     if (PyArray_ISBYTESWAPPED(ndarray)) {
       // TODO
       return Status::NotImplemented("Byte-swapped arrays not supported");
     }
+    OwnedRef flattened;
+    if (PyArray_NDIM(ndarray) != 1) {
+      // GH-49644: a fixed-size list (e.g. fixed-shape-tensor storage) is built
+      // from a multi- or 0-dimensional array by flattening it in C order.
+      if (this->list_type_->id() != Type::FIXED_SIZE_LIST) {
+        return Status::Invalid("Can only convert 1-dimensional array values of 
",
+                               this->list_type_->ToString(), " to a 
variable-sized list");
+      }
+      // Get an aligned, C-contiguous array (copying only if needed).
+      PyObject* contiguous =
+          PyArray_CheckFromAny(value, nullptr, /*min_depth=*/0, 
/*max_depth=*/0,
+                               NPY_ARRAY_C_CONTIGUOUS | NPY_ARRAY_ALIGNED, 
nullptr);
+      RETURN_IF_PYERROR();
+      flattened.reset(
+          PyArray_Ravel(reinterpret_cast<PyArrayObject*>(contiguous), 
NPY_CORDER));
+      Py_DECREF(contiguous);
+      RETURN_IF_PYERROR();
+      value = flattened.obj();
+      ndarray = reinterpret_cast<PyArrayObject*>(value);
+    }
     const int64_t size = PyArray_SIZE(ndarray);
     RETURN_NOT_OK(AppendTo(this->list_type_, size));
     RETURN_NOT_OK(this->list_builder_->ValidateOverflow(size));
diff --git a/python/pyarrow/tests/test_array.py 
b/python/pyarrow/tests/test_array.py
index 0205db2393..adc3e097b5 100644
--- a/python/pyarrow/tests/test_array.py
+++ b/python/pyarrow/tests/test_array.py
@@ -2924,6 +2924,30 @@ def test_array_from_invalid_dim_raises():
         pa.array(arr0d)
 
 
[email protected]
+def test_fixed_size_list_from_multidim_ndarray():
+    arr = pa.array([np.array([[1, 2, 3]], dtype=np.int64),
+                    np.array([[4, 5, 6]], dtype=np.int64)],
+                   type=pa.list_(pa.int64(), 3))
+    assert arr.type == pa.list_(pa.int64(), 3)
+    assert arr.to_pylist() == [[1, 2, 3], [4, 5, 6]]
+
+    arr = pa.array([np.array([[1, 2], [3, 4]], dtype=np.int64)],
+                   type=pa.list_(pa.int64(), 4))
+    assert arr.to_pylist() == [[1, 2, 3, 4]]
+
+    with pytest.raises(pa.lib.ArrowInvalid):
+        pa.array([np.array([[1, 2], [3, 4]], dtype=np.int64)],
+                 type=pa.list_(pa.int64(), 3))
+
+    with pytest.raises(pa.lib.ArrowInvalid, match=r"array values of .*int64"):
+        pa.array([np.array([[1, 2, 3]], dtype=np.int64)],
+                 type=pa.list_(pa.int64()))
+
+    arr = pa.array([np.array(1, dtype=np.int64)], type=pa.list_(pa.int64(), 1))
+    assert arr.to_pylist() == [[1]]
+
+
 @pytest.mark.numpy
 def test_array_from_strided_bool():
     # ARROW-6325
diff --git a/python/pyarrow/tests/test_extension_type.py 
b/python/pyarrow/tests/test_extension_type.py
index 1adbd4e980..35b801eca8 100644
--- a/python/pyarrow/tests/test_extension_type.py
+++ b/python/pyarrow/tests/test_extension_type.py
@@ -1730,6 +1730,72 @@ def test_tensor_array_from_numpy(np_type_str):
         pa.FixedShapeTensorArray.from_numpy_ndarray(arr, dim_names=[0, 1])
 
 
[email protected]
[email protected]("np_type_str", ("int8", "int64", "float32"))
+def test_tensor_array_from_list_of_ndarrays(np_type_str):
+    np_dtype = np.dtype(np_type_str)
+    tensor_type = pa.fixed_shape_tensor(pa.from_numpy_dtype(np_dtype), (2, 3))
+
+    elements = [
+        np.arange(6, dtype=np_dtype).reshape(2, 3),
+        np.arange(6, 12, dtype=np_dtype).reshape(2, 3),
+    ]
+    result = pa.array(elements, type=tensor_type)
+    assert isinstance(result, pa.FixedShapeTensorArray)
+    assert result.type == tensor_type
+    assert len(result) == 2
+
+    expected = pa.FixedShapeTensorArray.from_numpy_ndarray(np.stack(elements))
+    assert result.storage.equals(expected.storage)
+
+    for scalar, original in zip(result, elements):
+        np.testing.assert_array_equal(scalar.to_numpy(), original)
+
+    tensor_3d = pa.fixed_shape_tensor(pa.from_numpy_dtype(np_dtype), (2, 2, 3))
+    elements_3d = [np.arange(12, dtype=np_dtype).reshape(2, 2, 3)]
+    result_3d = pa.array(elements_3d, type=tensor_3d)
+    assert result_3d.type == tensor_3d
+    np.testing.assert_array_equal(result_3d[0].to_numpy(), elements_3d[0])
+
+    result_with_null = pa.array([elements[0], None], type=tensor_type)
+    assert result_with_null.null_count == 1
+    assert result_with_null[1].as_py() is None
+
+    with pytest.raises(ValueError, match="shape"):
+        pa.array([np.arange(6, dtype=np_dtype).reshape(3, 2)], 
type=tensor_type)
+
+    permuted_type = pa.fixed_shape_tensor(
+        pa.from_numpy_dtype(np_dtype), (2, 3), permutation=[1, 0])
+    with pytest.raises(NotImplementedError, match="permutation"):
+        pa.array(elements, type=permuted_type)
+
+
[email protected]
+def test_tensor_array_from_list_mixed_layout():
+    # C- and F-ordered arrays with the same values must produce the same
+    # result, since the values are always flattened in C order.
+    tensor_type = pa.fixed_shape_tensor(pa.int64(), (2, 3))
+    raw = [[1, 2, 3], [4, 5, 6]]
+    c_arr = np.array(raw, order="C")
+    f_arr = np.array(raw, order="F")
+    assert np.array_equal(c_arr, f_arr)
+    assert c_arr.tobytes("A") != f_arr.tobytes("A")
+
+    same = pa.array([c_arr, c_arr], type=tensor_type)
+    mixed = pa.array([c_arr, f_arr], type=tensor_type)
+    assert mixed.equals(same)
+    assert mixed.storage.to_pylist() == [[1, 2, 3, 4, 5, 6], [1, 2, 3, 4, 5, 
6]]
+
+
[email protected]
+def test_tensor_array_from_list_of_0d_arrays():
+    tensor_type = pa.fixed_shape_tensor(pa.int64(), ())
+    result = pa.array([np.array(1, dtype=np.int64), np.array(2, 
dtype=np.int64)],
+                      type=tensor_type)
+    assert result.type == tensor_type
+    assert result.storage.to_pylist() == [[1], [2]]
+
+
 @pytest.mark.numpy
 @pytest.mark.parametrize("tensor_type", (
     pa.fixed_shape_tensor(pa.int8(), [2, 2, 3]),

Reply via email to