Skip to content

Commit a9df51b

Browse files
CoW: add readonly flag to ExtensionArrays, return read-only EA/ndarray in .array/EA.to_numpy()
1 parent 27928ed commit a9df51b

File tree

18 files changed

+215
-10
lines changed

18 files changed

+215
-10
lines changed

pandas/core/arrays/_mixins.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ def shift(self, periods: int = 1, fill_value=None) -> Self:
252252
return self._from_backing_data(new_values)
253253

254254
def __setitem__(self, key, value) -> None:
255+
if self._readonly:
256+
raise ValueError("Cannot modify readonly array")
257+
255258
key = check_array_indexer(self, key)
256259
value = self._validate_setitem_value(value)
257260
self._ndarray[key] = value

pandas/core/arrays/arrow/array.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1967,6 +1967,9 @@ def __setitem__(self, key, value) -> None:
19671967
-------
19681968
None
19691969
"""
1970+
if self._readonly:
1971+
raise ValueError("Cannot modify readonly array")
1972+
19701973
# GH50085: unwrap 1D indexers
19711974
if isinstance(key, tuple) and len(key) == 1:
19721975
key = key[0]

pandas/core/arrays/base.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
validate_insert_loc,
4141
)
4242

43+
from pandas.core.dtypes.astype import astype_is_view
4344
from pandas.core.dtypes.cast import maybe_cast_pointwise_result
4445
from pandas.core.dtypes.common import (
4546
is_list_like,
@@ -269,6 +270,8 @@ class ExtensionArray:
269270
# strictly less than 2000 to be below Index.__pandas_priority__.
270271
__pandas_priority__ = 1000
271272

273+
_readonly = False
274+
272275
# ------------------------------------------------------------------------
273276
# Constructors
274277
# ------------------------------------------------------------------------
@@ -482,6 +485,11 @@ def __setitem__(self, key, value) -> None:
482485
Returns
483486
-------
484487
None
488+
489+
Raises
490+
------
491+
ValueError
492+
If the array is readonly and modification is attempted.
485493
"""
486494
# Some notes to the ExtensionArray implementer who may have ended up
487495
# here. While this method is not required for the interface, if you
@@ -501,8 +509,59 @@ def __setitem__(self, key, value) -> None:
501509
# __init__ method coerces that value, then so should __setitem__
502510
# Note, also, that Series/DataFrame.where internally use __setitem__
503511
# on a copy of the data.
512+
# Check if the array is readonly
513+
if self._readonly:
514+
raise ValueError("Cannot modify readonly array")
515+
504516
raise NotImplementedError(f"{type(self)} does not implement __setitem__.")
505517

518+
@property
519+
def readonly(self) -> bool:
520+
"""
521+
Whether the array is readonly.
522+
523+
If True, attempts to modify the array via __setitem__ will raise
524+
a ValueError.
525+
526+
Returns
527+
-------
528+
bool
529+
True if the array is readonly, False otherwise.
530+
531+
Examples
532+
--------
533+
>>> arr = pd.array([1, 2, 3])
534+
>>> arr.readonly
535+
False
536+
>>> arr.readonly = True
537+
>>> arr[0] = 5
538+
Traceback (most recent call last):
539+
...
540+
ValueError: Cannot modify readonly ExtensionArray
541+
"""
542+
return getattr(self, "_readonly", False)
543+
544+
@readonly.setter
545+
def readonly(self, value: bool) -> None:
546+
"""
547+
Set the readonly state of the array.
548+
549+
Parameters
550+
----------
551+
value : bool
552+
True to make the array readonly, False to make it writable.
553+
554+
Examples
555+
--------
556+
>>> arr = pd.array([1, 2, 3])
557+
>>> arr.readonly = True
558+
>>> arr.readonly
559+
True
560+
"""
561+
if not isinstance(value, bool):
562+
raise TypeError("readonly must be a boolean")
563+
self._readonly = value
564+
506565
def __len__(self) -> int:
507566
"""
508567
Length of this array
@@ -595,8 +654,14 @@ def to_numpy(
595654
result = np.asarray(self, dtype=dtype)
596655
if copy or na_value is not lib.no_default:
597656
result = result.copy()
657+
elif self._readonly and astype_is_view(self.dtype, result.dtype):
658+
# If the ExtensionArray is readonly, make the numpy array readonly too
659+
result = result.view()
660+
result.flags.writeable = False
661+
598662
if na_value is not lib.no_default:
599663
result[self.isna()] = na_value # type: ignore[index]
664+
600665
return result
601666

602667
# ------------------------------------------------------------------------

pandas/core/arrays/datetimelike.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,12 @@ def __array__(
368368

369369
if copy is True:
370370
return np.array(self._ndarray, dtype=dtype)
371-
return self._ndarray
371+
372+
result = self._ndarray
373+
if self._readonly:
374+
result = result.view()
375+
result.flags.writeable = False
376+
return result
372377

373378
@overload
374379
def __getitem__(self, key: ScalarIndexer) -> DTScalarOrNaT: ...

pandas/core/arrays/interval.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,9 @@ def __getitem__(self, key: PositionalIndexer) -> Self | IntervalOrNA:
729729
return self._simple_new(left, right, dtype=self.dtype) # type: ignore[arg-type]
730730

731731
def __setitem__(self, key, value) -> None:
732+
if self._readonly:
733+
raise ValueError("Cannot modify readonly array")
734+
732735
value_left, value_right = self._validate_setitem_value(value)
733736
key = check_array_indexer(self, key)
734737

pandas/core/arrays/masked.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pandas.errors import AbstractMethodError
2424
from pandas.util._decorators import doc
2525

26+
from pandas.core.dtypes.astype import astype_is_view
2627
from pandas.core.dtypes.base import ExtensionDtype
2728
from pandas.core.dtypes.common import (
2829
is_bool,
@@ -290,6 +291,9 @@ def _validate_setitem_value(self, value):
290291
raise TypeError(f"Invalid value '{value!s}' for dtype '{self.dtype}'")
291292

292293
def __setitem__(self, key, value) -> None:
294+
if self._readonly:
295+
raise ValueError("Cannot modify readonly array")
296+
293297
key = check_array_indexer(self, key)
294298

295299
if is_scalar(value):
@@ -520,6 +524,9 @@ def to_numpy(
520524
with warnings.catch_warnings():
521525
warnings.filterwarnings("ignore", category=RuntimeWarning)
522526
data = self._data.astype(dtype, copy=copy)
527+
if self._readonly and astype_is_view(self.dtype, dtype):
528+
data = data.view()
529+
data.flags.writeable = False
523530
return data
524531

525532
@doc(ExtensionArray.tolist)
@@ -596,7 +603,12 @@ def __array__(
596603
if copy is False:
597604
if not self._hasna:
598605
# special case, here we can simply return the underlying data
599-
return np.array(self._data, dtype=dtype, copy=copy)
606+
result = np.array(self._data, dtype=dtype, copy=copy)
607+
# If the ExtensionArray is readonly, make the numpy array readonly too
608+
if self._readonly:
609+
result = result.view()
610+
result.flags.writeable = False
611+
return result
600612
raise ValueError(
601613
"Unable to avoid copy while creating an array as requested."
602614
)

pandas/core/arrays/numpy_.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from pandas._libs.tslibs import is_supported_dtype
1313
from pandas.compat.numpy import function as nv
1414

15-
from pandas.core.dtypes.astype import astype_array
15+
from pandas.core.dtypes.astype import (
16+
astype_array,
17+
astype_is_view,
18+
)
1619
from pandas.core.dtypes.cast import construct_1d_object_array_from_listlike
1720
from pandas.core.dtypes.common import pandas_dtype
1821
from pandas.core.dtypes.dtypes import NumpyEADtype
@@ -160,8 +163,19 @@ def __array__(
160163
) -> np.ndarray:
161164
if copy is not None:
162165
# Note: branch avoids `copy=None` for NumPy 1.x support
163-
return np.array(self._ndarray, dtype=dtype, copy=copy)
164-
return np.asarray(self._ndarray, dtype=dtype)
166+
result = np.array(self._ndarray, dtype=dtype, copy=copy)
167+
else:
168+
result = np.asarray(self._ndarray, dtype=dtype)
169+
170+
if (
171+
self._readonly
172+
and not copy
173+
and (dtype is None or astype_is_view(self.dtype, dtype))
174+
):
175+
result = result.view()
176+
result.flags.writeable = False
177+
178+
return result
165179

166180
def __array_ufunc__(self, ufunc: np.ufunc, method: str, *inputs, **kwargs):
167181
# Lightly modified version of
@@ -512,6 +526,9 @@ def to_numpy(
512526
result[mask] = na_value
513527
else:
514528
result = self._ndarray
529+
if not copy and self._readonly:
530+
result = result.view()
531+
result.flags.writeable = False
515532

516533
result = np.asarray(result, dtype=dtype)
517534

pandas/core/arrays/period.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,11 @@ def __array__(
393393
# For NumPy 1.x compatibility we cannot use copy=None. And
394394
# `copy=False` has the meaning of `copy=None` here:
395395
if not copy:
396-
return np.asarray(self.asi8, dtype=dtype)
396+
result = np.asarray(self.asi8, dtype=dtype)
397+
if self._readonly:
398+
result = result.view()
399+
result.flags.writeable = False
400+
return result
397401
else:
398402
return np.array(self.asi8, dtype=dtype)
399403

pandas/core/arrays/sparse/array.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,11 @@ def __array__(
562562
if copy is True:
563563
return np.array(self.sp_values)
564564
else:
565-
return self.sp_values
565+
result = self.sp_values
566+
if self._readonly:
567+
result = result.view()
568+
result.flags.writeable = False
569+
return result
566570

567571
if copy is False:
568572
raise ValueError(
@@ -591,6 +595,8 @@ def __array__(
591595
return out
592596

593597
def __setitem__(self, key, value) -> None:
598+
if self._readonly:
599+
raise ValueError("Cannot modify readonly array")
594600
# I suppose we could allow setting of non-fill_value elements.
595601
# TODO(SparseArray.__setitem__): remove special cases in
596602
# ExtensionBlock.where

pandas/core/arrays/string_.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,9 @@ def _maybe_convert_setitem_value(self, value):
769769
return value
770770

771771
def __setitem__(self, key, value) -> None:
772+
if self._readonly:
773+
raise ValueError("Cannot modify readonly array")
774+
772775
value = self._maybe_convert_setitem_value(value)
773776

774777
key = check_array_indexer(self, key)

0 commit comments

Comments
 (0)