Skip to content

Commit bca5c9a

Browse files
committed
GH-50429: [Python] Convert maps to dicts without per-element Scalars in to_pylist
Thread maps_as_pydicts through the scalar-free _getitem_py mechanism instead of routing it to the Scalar-based path. MapArray builds the dict directly from the flattened keys/items children; when the dict size reveals duplicate keys, the row is redone with the per-key loop so the 'lossy' warnings and 'strict' KeyError match MapScalar.as_py exactly. Invalid values still raise the same ValueError when a map value is converted (including null rows), non-map arrays still ignore the option, and the option still propagates through nested types. Unspecialized types keep the exact Scalar fallback, which now receives the option. Benchmark (M4 Max, 1M rows of 2-entry maps, 10% nulls): to_pylist(maps_as_pydicts='lossy') 2.20s -> 0.10s (~21x). The dict form is also faster than the default association-list form (0.78s), which allocates a 2-tuple per entry. Co-authored-by: Isaac
1 parent 728584f commit bca5c9a

3 files changed

Lines changed: 87 additions & 28 deletions

File tree

python/pyarrow/array.pxi

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,18 +1865,14 @@ cdef class Array(_PandasConvertible):
18651865
"""
18661866
self._assert_cpu()
18671867
cdef int64_t i, n = self.length()
1868-
if maps_as_pydicts is not None:
1869-
# Converting maps to dicts has per-entry semantics (duplicate-key
1870-
# detection); use the Scalar-based conversion for exact behavior.
1871-
return [x.as_py(maps_as_pydicts=maps_as_pydicts) for x in self]
1872-
return [self._getitem_py(i) for i in range(n)]
1868+
return [self._getitem_py(i, maps_as_pydicts) for i in range(n)]
18731869

1874-
cdef object _getitem_py(self, int64_t i):
1870+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
18751871
# Return self[i] as a Python object, without creating a Python Scalar
18761872
# (nor, for nested types, per-row Array wrappers) where a subclass
18771873
# provides a specialization; this base implementation goes through
18781874
# Scalar.as_py and thus preserves its semantics exactly (see GH-50326).
1879-
return self.getitem(i).as_py()
1875+
return self.getitem(i).as_py(maps_as_pydicts=maps_as_pydicts)
18801876

18811877
def tolist(self):
18821878
"""
@@ -2457,7 +2453,7 @@ cdef class BooleanArray(Array):
24572453
Concrete class for Arrow arrays of boolean data type.
24582454
"""
24592455

2460-
cdef object _getitem_py(self, int64_t i):
2456+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
24612457
if self.ap.IsNull(i):
24622458
return None
24632459
return (<CBooleanArray*> self.ap).Value(i)
@@ -2476,7 +2472,7 @@ cdef class NumericArray(Array):
24762472
A base class for Arrow numeric arrays.
24772473
"""
24782474

2479-
cdef object _getitem_py(self, int64_t i):
2475+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
24802476
cdef Type tid = self.ap.type_id()
24812477
if self.ap.IsNull(i):
24822478
return None
@@ -2502,7 +2498,7 @@ cdef class NumericArray(Array):
25022498
return (<CUInt8Array*> self.ap).Value(i)
25032499
# Subclasses whose as_py returns non-primitive objects (dates, times,
25042500
# timestamps, durations, half floats, ...) use the exact Scalar path.
2505-
return Array._getitem_py(self, i)
2501+
return Array._getitem_py(self, i, maps_as_pydicts)
25062502

25072503

25082504
cdef class IntegerArray(NumericArray):
@@ -2822,15 +2818,15 @@ cdef class ListArray(BaseListArray):
28222818
Concrete class for Arrow arrays of a list data type.
28232819
"""
28242820

2825-
cdef object _getitem_py(self, int64_t i):
2821+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
28262822
cdef CListArray* arr = <CListArray*> self.ap
28272823
if arr.IsNull(i):
28282824
return None
28292825
if self._children_cache is None:
28302826
self._children_cache = pyarrow_wrap_array(arr.values())
28312827
cdef Array values = <Array> self._children_cache
28322828
cdef int64_t j, start = arr.value_offset(i), end = arr.value_offset(i + 1)
2833-
return [values._getitem_py(j) for j in range(start, end)]
2829+
return [values._getitem_py(j, maps_as_pydicts) for j in range(start, end)]
28342830

28352831
@staticmethod
28362832
def from_arrays(offsets, values, DataType type=None, MemoryPool pool=None, mask=None):
@@ -3017,15 +3013,15 @@ cdef class LargeListArray(BaseListArray):
30173013
Identical to ListArray, but 64-bit offsets.
30183014
"""
30193015

3020-
cdef object _getitem_py(self, int64_t i):
3016+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
30213017
cdef CLargeListArray* arr = <CLargeListArray*> self.ap
30223018
if arr.IsNull(i):
30233019
return None
30243020
if self._children_cache is None:
30253021
self._children_cache = pyarrow_wrap_array(arr.values())
30263022
cdef Array values = <Array> self._children_cache
30273023
cdef int64_t j, start = arr.value_offset(i), end = arr.value_offset(i + 1)
3028-
return [values._getitem_py(j) for j in range(start, end)]
3024+
return [values._getitem_py(j, maps_as_pydicts) for j in range(start, end)]
30293025

30303026
@staticmethod
30313027
def from_arrays(offsets, values, DataType type=None, MemoryPool pool=None, mask=None):
@@ -3617,18 +3613,51 @@ cdef class MapArray(ListArray):
36173613
Concrete class for Arrow arrays of a map data type.
36183614
"""
36193615

3620-
cdef object _getitem_py(self, int64_t i):
3616+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
36213617
cdef CListArray* arr = <CListArray*> self.ap
3618+
cdef bint as_dicts = maps_as_pydicts is not None
3619+
if as_dicts and maps_as_pydicts != "lossy" and maps_as_pydicts != "strict":
3620+
# Matches MapScalar.as_py, which validates before the null check.
3621+
raise ValueError(
3622+
"Invalid value for 'maps_as_pydicts': "
3623+
+ "valid values are 'lossy', 'strict' or `None` (default). "
3624+
+ f"Received {maps_as_pydicts!r}."
3625+
)
36223626
if arr.IsNull(i):
36233627
return None
36243628
if self._children_cache is None:
36253629
self._children_cache = (self.keys, self.items)
36263630
cdef Array keys = <Array> (<tuple> self._children_cache)[0]
36273631
cdef Array items = <Array> (<tuple> self._children_cache)[1]
36283632
cdef int64_t j, start = arr.value_offset(i), end = arr.value_offset(i + 1)
3629-
# Matches MapScalar.as_py with the default maps_as_pydicts=None:
3630-
# an association list of (key, value) tuples.
3631-
return [(keys._getitem_py(j), items._getitem_py(j)) for j in range(start, end)]
3633+
if not as_dicts:
3634+
# Matches MapScalar.as_py with the default maps_as_pydicts=None:
3635+
# an association list of (key, value) tuples.
3636+
return [
3637+
(keys._getitem_py(j, None), items._getitem_py(j, maps_as_pydicts))
3638+
for j in range(start, end)
3639+
]
3640+
cdef dict result = {}
3641+
for j in range(start, end):
3642+
result[keys._getitem_py(j, None)] = items._getitem_py(j, maps_as_pydicts)
3643+
if len(result) == end - start:
3644+
return result
3645+
# Duplicate keys: redo the row with the per-key loop so the 'lossy'
3646+
# warnings and 'strict' KeyError match MapScalar.as_py exactly.
3647+
result = {}
3648+
for j in range(start, end):
3649+
key = keys._getitem_py(j, None)
3650+
if key in result:
3651+
if maps_as_pydicts == "strict":
3652+
raise KeyError(
3653+
"Converting to Python dictionary is not supported in strict mode "
3654+
f"when duplicate keys are present (duplicate key was '{key}')."
3655+
)
3656+
else:
3657+
warnings.warn(
3658+
f"Encountered key '{key}' which was already encountered.")
3659+
result[key] = items._getitem_py(j, maps_as_pydicts)
3660+
return result
36323661

36333662
@staticmethod
36343663
def from_arrays(offsets, keys, items, DataType type=None, MemoryPool pool=None, mask=None):
@@ -3767,15 +3796,15 @@ cdef class FixedSizeListArray(BaseListArray):
37673796
Concrete class for Arrow arrays of a fixed size list data type.
37683797
"""
37693798

3770-
cdef object _getitem_py(self, int64_t i):
3799+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
37713800
cdef CFixedSizeListArray* arr = <CFixedSizeListArray*> self.ap
37723801
if arr.IsNull(i):
37733802
return None
37743803
if self._children_cache is None:
37753804
self._children_cache = pyarrow_wrap_array(arr.values())
37763805
cdef Array values = <Array> self._children_cache
37773806
cdef int64_t j, start = arr.value_offset(i), end = arr.value_offset(i + 1)
3778-
return [values._getitem_py(j) for j in range(start, end)]
3807+
return [values._getitem_py(j, maps_as_pydicts) for j in range(start, end)]
37793808

37803809
@staticmethod
37813810
def from_arrays(values, list_size=None, DataType type=None, mask=None):
@@ -4063,7 +4092,7 @@ cdef class StringArray(Array):
40634092
Concrete class for Arrow arrays of string (or utf8) data type.
40644093
"""
40654094

4066-
cdef object _getitem_py(self, int64_t i):
4095+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
40674096
cdef:
40684097
int32_t length
40694098
const uint8_t* data
@@ -4105,7 +4134,7 @@ cdef class LargeStringArray(Array):
41054134
Concrete class for Arrow arrays of large string (or utf8) data type.
41064135
"""
41074136

4108-
cdef object _getitem_py(self, int64_t i):
4137+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
41094138
cdef:
41104139
int64_t length
41114140
const uint8_t* data
@@ -4152,7 +4181,7 @@ cdef class BinaryArray(Array):
41524181
Concrete class for Arrow arrays of variable-sized binary data type.
41534182
"""
41544183

4155-
cdef object _getitem_py(self, int64_t i):
4184+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
41564185
cdef:
41574186
int32_t length
41584187
const uint8_t* data
@@ -4175,7 +4204,7 @@ cdef class LargeBinaryArray(Array):
41754204
Concrete class for Arrow arrays of large variable-sized binary data type.
41764205
"""
41774206

4178-
cdef object _getitem_py(self, int64_t i):
4207+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
41794208
cdef:
41804209
int64_t length
41814210
const uint8_t* data
@@ -4357,7 +4386,7 @@ cdef class StructArray(Array):
43574386
Concrete class for Arrow arrays of a struct data type.
43584387
"""
43594388

4360-
cdef object _getitem_py(self, int64_t i):
4389+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts):
43614390
if self.ap.IsNull(i):
43624391
return None
43634392
cdef int64_t k, num_fields = self.type.num_fields
@@ -4372,13 +4401,13 @@ cdef class StructArray(Array):
43724401
names, [self.field(k) for k in range(num_fields)])
43734402
names = (<tuple> self._children_cache)[0]
43744403
if names is None:
4375-
return Array._getitem_py(self, i)
4404+
return Array._getitem_py(self, i, maps_as_pydicts)
43764405
fields = (<tuple> self._children_cache)[1]
43774406
cdef Array field_arr
43784407
result = {}
43794408
for k in range(num_fields):
43804409
field_arr = <Array> fields[k]
4381-
result[names[k]] = field_arr._getitem_py(i)
4410+
result[names[k]] = field_arr._getitem_py(i, maps_as_pydicts)
43824411
return result
43834412

43844413
def field(self, index):

python/pyarrow/lib.pxd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ cdef class Array(_PandasConvertible):
296296

297297
cdef void init(self, const shared_ptr[CArray]& sp_array) except *
298298
cdef getitem(self, int64_t i)
299-
cdef object _getitem_py(self, int64_t i)
299+
cdef object _getitem_py(self, int64_t i, object maps_as_pydicts)
300300
cdef int64_t length(self)
301301
cdef void _assert_cpu(self) except *
302302

python/pyarrow/tests/test_array.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,36 @@ def test_to_pylist_bulk_paths():
515515
dup.to_pylist()
516516

517517

518+
def test_to_pylist_maps_as_pydicts():
519+
# GH-50429: maps_as_pydicts converts through the scalar-free path; the
520+
# semantics must match MapScalar.as_py exactly.
521+
map_type = pa.map_(pa.string(), pa.int32())
522+
arrays = [
523+
pa.array([[("k1", 1), ("k2", None)], None, []], type=map_type),
524+
pa.array([[[("k", 1)], None], None], type=pa.list_(map_type)),
525+
pa.array([[("o", [("i", 5)])]],
526+
type=pa.map_(pa.string(), map_type)),
527+
pa.array([{"m": [("k", 1)]}, None],
528+
type=pa.struct([("m", map_type)])),
529+
]
530+
for arr in arrays:
531+
for mode in ("lossy", "strict"):
532+
for view in (arr, arr.slice(1)):
533+
expected = [x.as_py(maps_as_pydicts=mode) for x in view]
534+
assert view.to_pylist(maps_as_pydicts=mode) == expected
535+
536+
dup = pa.array([[("k", 1), ("k", 2)]], type=map_type)
537+
with pytest.warns(UserWarning, match="already encountered"):
538+
assert dup.to_pylist(maps_as_pydicts="lossy") == [{"k": 2}]
539+
with pytest.raises(KeyError, match="strict mode"):
540+
dup.to_pylist(maps_as_pydicts="strict")
541+
with pytest.raises(ValueError, match="Invalid value for 'maps_as_pydicts'"):
542+
dup.to_pylist(maps_as_pydicts="bogus")
543+
# invalid values are only rejected when a map value is converted,
544+
# matching the Scalar-based behavior
545+
assert pa.array([1, 2]).to_pylist(maps_as_pydicts="bogus") == [1, 2]
546+
547+
518548
def test_array_slice():
519549
arr = pa.array(range(10))
520550

0 commit comments

Comments
 (0)