1from __future__ import annotations
2
3from functools import wraps
4from typing import (
5 TYPE_CHECKING,
6 Any,
7 Literal,
8 Self,
9 cast,
10 overload,
11)
12
13import numpy as np
14
15from pandas._libs import lib
16from pandas._libs.arrays import NDArrayBacked
17from pandas._libs.tslibs import is_supported_dtype
18from pandas.errors import AbstractMethodError
19from pandas.util._validators import (
20 validate_bool_kwarg,
21 validate_insert_loc,
22)
23
24from pandas.core.dtypes.cast import construct_1d_object_array_from_listlike
25from pandas.core.dtypes.common import pandas_dtype
26from pandas.core.dtypes.dtypes import (
27 DatetimeTZDtype,
28 ExtensionDtype,
29 PeriodDtype,
30)
31from pandas.core.dtypes.missing import array_equivalent
32
33from pandas.core import missing
34from pandas.core.algorithms import (
35 take,
36 unique,
37 value_counts_internal as value_counts,
38)
39from pandas.core.array_algos.quantile import quantile_with_mask
40from pandas.core.array_algos.transforms import shift
41from pandas.core.arrays.base import ExtensionArray
42from pandas.core.construction import extract_array
43from pandas.core.indexers import (
44 check_array_indexer,
45 getitem_returns_view,
46)
47from pandas.core.sorting import nargminmax
48
49if TYPE_CHECKING:
50 from collections.abc import Sequence
51
52 from pandas._typing import (
53 ArrayLike,
54 AxisInt,
55 Dtype,
56 F,
57 FillnaOptions,
58 NumpySorter,
59 NumpyValueArrayLike,
60 PositionalIndexer2D,
61 PositionalIndexerTuple,
62 ScalarIndexer,
63 SequenceIndexer,
64 Shape,
65 SortKind,
66 TakeIndexer,
67 npt,
68 )
69
70 from pandas import Series
71
72
73def ravel_compat(meth: F) -> F:
74 """
75 Decorator to ravel a 2D array before passing it to a cython operation,
76 then reshape the result to our own shape.
77 """
78
79 @wraps(meth)
80 def method(self, *args, **kwargs):
81 if self.ndim == 1:
82 return meth(self, *args, **kwargs)
83
84 flags = self._ndarray.flags
85 flat = self.ravel("K")
86 result = meth(flat, *args, **kwargs)
87 order = "F" if flags.f_contiguous else "C"
88 return result.reshape(self.shape, order=order)
89
90 return cast("F", method)
91
92
93class NDArrayBackedExtensionArray(NDArrayBacked, ExtensionArray):
94 """
95 ExtensionArray that is backed by a single NumPy ndarray.
96 """
97
98 _ndarray: np.ndarray
99
100 # scalar used to denote NA value inside our self._ndarray, e.g. -1
101 # for Categorical, iNaT for Period. Outside of object dtype,
102 # self.isna() should be exactly locations in self._ndarray with
103 # _internal_fill_value.
104 _internal_fill_value: Any
105
106 def _box_func(self, x):
107 """
108 Wrap numpy type in our dtype.type if necessary.
109 """
110 return x
111
112 def _validate_scalar(self, value):
113 # used by NDArrayBackedExtensionIndex.insert
114 raise AbstractMethodError(self)
115
116 # ------------------------------------------------------------------------
117
118 @overload
119 def view(self, dtype: None = ...) -> Self: ...
120
121 @overload
122 def view(self, dtype: Dtype | None = ...) -> ArrayLike: ...
123
124 def view(self, dtype: Dtype | None = None) -> ArrayLike:
125 # We handle datetime64, datetime64tz, timedelta64, and period
126 # dtypes here. Everything else we pass through to the underlying
127 # ndarray.
128 if dtype is None or dtype is self.dtype:
129 return self._from_backing_data(self._ndarray)
130
131 if isinstance(dtype, type):
132 # we sometimes pass non-dtype objects, e.g np.ndarray;
133 # pass those through to the underlying ndarray
134 return self._ndarray.view(dtype)
135
136 dtype = pandas_dtype(dtype)
137 arr = self._ndarray
138
139 if isinstance(dtype, PeriodDtype):
140 cls = dtype.construct_array_type()
141 return cls(arr.view("i8"), dtype=dtype)
142 elif isinstance(dtype, DatetimeTZDtype):
143 dt_cls = dtype.construct_array_type()
144 dt64_values = arr.view(f"M8[{dtype.unit}]")
145 return dt_cls._simple_new(dt64_values, dtype=dtype)
146 elif lib.is_np_dtype(dtype, "M") and is_supported_dtype(dtype):
147 from pandas.core.arrays import DatetimeArray
148
149 dt64_values = arr.view(dtype)
150 return DatetimeArray._simple_new(dt64_values, dtype=dtype)
151 elif lib.is_np_dtype(dtype, "m") and is_supported_dtype(dtype):
152 from pandas.core.arrays import TimedeltaArray
153
154 td64_values = arr.view(dtype)
155 return TimedeltaArray._simple_new(td64_values, dtype=dtype)
156 # error: Argument "dtype" to "view" of "ndarray" has incompatible type
157 # "ExtensionDtype | dtype[Any]"; expected "dtype[Any] | _HasDType[dtype[Any]]"
158 return arr.view(dtype=dtype) # type: ignore[arg-type]
159
160 def take(
161 self,
162 indices: TakeIndexer,
163 *,
164 allow_fill: bool = False,
165 fill_value: Any = None,
166 axis: AxisInt = 0,
167 ) -> Self:
168 if allow_fill:
169 fill_value = self._validate_scalar(fill_value)
170
171 new_data = take(
172 self._ndarray,
173 indices,
174 allow_fill=allow_fill,
175 fill_value=fill_value,
176 axis=axis,
177 )
178 return self._from_backing_data(new_data)
179
180 # ------------------------------------------------------------------------
181
182 def equals(self, other) -> bool:
183 if type(self) is not type(other):
184 return False
185 if self.dtype != other.dtype:
186 return False
187 return bool(array_equivalent(self._ndarray, other._ndarray, dtype_equal=True))
188
189 @classmethod
190 def _from_factorized(cls, values, original):
191 assert values.dtype == original._ndarray.dtype
192 return original._from_backing_data(values)
193
194 def _values_for_argsort(self) -> np.ndarray:
195 return self._ndarray
196
197 def _values_for_factorize(self):
198 return self._ndarray, self._internal_fill_value
199
200 def _hash_pandas_object(
201 self, *, encoding: str, hash_key: str, categorize: bool
202 ) -> npt.NDArray[np.uint64]:
203 from pandas.core.util.hashing import hash_array
204
205 values = self._ndarray
206 return hash_array(
207 values, encoding=encoding, hash_key=hash_key, categorize=categorize
208 )
209
210 def _cast_pointwise_result(self, values: ArrayLike) -> ArrayLike:
211 if not (isinstance(values, np.ndarray) and values.dtype == object):
212 values = construct_1d_object_array_from_listlike(values) # type: ignore[arg-type]
213 return lib.maybe_convert_objects(values, convert_non_numeric=True)
214
215 # Signature of "argmin" incompatible with supertype "ExtensionArray"
216 def argmin(self, axis: AxisInt = 0, skipna: bool = True): # type: ignore[override]
217 # override base class by adding axis keyword
218 validate_bool_kwarg(skipna, "skipna")
219 if not skipna and self._hasna:
220 raise ValueError("Encountered an NA value with skipna=False")
221 return nargminmax(self, "argmin", axis=axis)
222
223 # Signature of "argmax" incompatible with supertype "ExtensionArray"
224 def argmax(self, axis: AxisInt = 0, skipna: bool = True): # type: ignore[override]
225 # override base class by adding axis keyword
226 validate_bool_kwarg(skipna, "skipna")
227 if not skipna and self._hasna:
228 raise ValueError("Encountered an NA value with skipna=False")
229 return nargminmax(self, "argmax", axis=axis)
230
231 def unique(self) -> Self:
232 new_data = unique(self._ndarray)
233 return self._from_backing_data(new_data)
234
235 def sort(
236 self,
237 *,
238 ascending: bool = True,
239 kind: SortKind = "quicksort",
240 na_position: str = "last",
241 ) -> None:
242 if self._readonly:
243 raise ValueError("Cannot modify read-only array")
244 sort_indices = self.argsort(
245 ascending=ascending, kind=kind, na_position=na_position
246 )
247 self._ndarray[:] = self._ndarray[sort_indices]
248
249 @classmethod
250 def _concat_same_type(
251 cls,
252 to_concat: Sequence[Self],
253 axis: AxisInt = 0,
254 ) -> Self:
255 """
256 Concatenate multiple arrays of this dtype.
257
258 Parameters
259 ----------
260 to_concat : sequence of this type
261
262 Returns
263 -------
264 ExtensionArray
265 """
266 if not lib.dtypes_all_equal([x.dtype for x in to_concat]):
267 dtypes = {str(x.dtype) for x in to_concat}
268 raise ValueError("to_concat must have the same dtype", dtypes)
269
270 return super()._concat_same_type(to_concat, axis=axis)
271
272 def searchsorted(
273 self,
274 value: NumpyValueArrayLike | ExtensionArray,
275 side: Literal["left", "right"] = "left",
276 sorter: NumpySorter | None = None,
277 ) -> npt.NDArray[np.intp] | np.intp:
278 """
279 Find indices where elements should be inserted to maintain order.
280
281 Find the indices into a sorted array `self` (a) such that, if the
282 corresponding elements in `value` were inserted before the indices,
283 the order of `self` would be preserved.
284
285 Assuming that `self` is sorted:
286
287 ====== ================================
288 `side` returned index `i` satisfies
289 ====== ================================
290 left ``self[i-1] < value <= self[i]``
291 right ``self[i-1] <= value < self[i]``
292 ====== ================================
293
294 Parameters
295 ----------
296 value : array-like, list or scalar
297 Value(s) to insert into `self`.
298 side : {'left', 'right'}, optional
299 If 'left', the index of the first suitable location found is given.
300 If 'right', return the last such index. If there is no suitable
301 index, return either 0 or N (where N is the length of `self`).
302 sorter : 1-D array-like, optional
303 Optional array of integer indices that sort array a into ascending
304 order. They are typically the result of argsort.
305
306 Returns
307 -------
308 array of ints or int
309 If value is array-like, array of insertion points.
310 If value is scalar, a single integer.
311
312 See Also
313 --------
314 numpy.searchsorted : Similar method from NumPy.
315 """
316 npvalue = self._validate_setitem_value(value)
317 return self._ndarray.searchsorted(npvalue, side=side, sorter=sorter)
318
319 def shift(self, periods: int = 1, fill_value=None) -> Self:
320 """
321 Shift values by desired number.
322
323 Newly introduced missing values are filled with
324 ``self.dtype.na_value``.
325
326 Parameters
327 ----------
328 periods : int, default 1
329 The number of periods to shift. Negative values are allowed
330 for shifting backwards.
331
332 fill_value : object, optional
333 The scalar value to use for newly introduced missing values.
334 The default is ``self.dtype.na_value``.
335
336 Returns
337 -------
338 ExtensionArray
339 Shifted.
340
341 Notes
342 -----
343 If ``self`` is empty or ``periods`` is 0, a copy of ``self`` is
344 returned.
345
346 If ``periods > len(self)``, then an array of size
347 len(self) is returned, with all values filled with
348 ``self.dtype.na_value``.
349 """
350 # NB: shift is always along axis=self.ndim-1
351 fill_value = self._validate_scalar(fill_value)
352 new_values = shift(self._ndarray, periods, fill_value)
353
354 return self._from_backing_data(new_values)
355
356 def __setitem__(self, key, value) -> None:
357 if self._readonly:
358 raise ValueError("Cannot modify read-only array")
359
360 key = check_array_indexer(self, key)
361 value = self._validate_setitem_value(value)
362 try:
363 self._ndarray[key] = value
364 except TypeError as err:
365 if self._ndarray.dtype.kind == "c":
366 # GH#54761 numpy raises TypeError for cases like setting a
367 # 1d sequence into a complex scalar position ("only 0-dimensional
368 # arrays can be converted to Python scalars"); normalize to
369 # ValueError for consistency with other numeric dtypes.
370 raise ValueError(*err.args) from err
371 raise
372
373 def _validate_setitem_value(self, value):
374 return value
375
376 @overload
377 def __getitem__(self, key: ScalarIndexer) -> Any: ...
378
379 @overload
380 def __getitem__(
381 self,
382 key: SequenceIndexer | PositionalIndexerTuple,
383 ) -> Self: ...
384
385 def __getitem__(
386 self,
387 key: PositionalIndexer2D,
388 ) -> Self | Any:
389 if lib.is_integer(key):
390 # fast-path
391 result = self._ndarray[key]
392 if self.ndim == 1:
393 return self._box_func(result)
394 result = self._from_backing_data(result)
395 if getitem_returns_view(self, key):
396 result._readonly = self._readonly
397 return result
398
399 # error: Incompatible types in assignment (expression has type "ExtensionArray",
400 # variable has type "Union[int, slice, ndarray]")
401 key = extract_array(key, extract_numpy=True) # type: ignore[assignment]
402 key = check_array_indexer(self, key)
403 result = self._ndarray[key]
404 if lib.is_scalar(result):
405 return self._box_func(result)
406
407 result = self._from_backing_data(result)
408 if getitem_returns_view(self, key):
409 result._readonly = self._readonly
410 return result
411
412 def _pad_or_backfill(
413 self,
414 *,
415 method: FillnaOptions,
416 limit: int | None = None,
417 limit_area: Literal["inside", "outside"] | None = None,
418 copy: bool = True,
419 ) -> Self:
420 mask = self.isna()
421 if mask.any():
422 # (for now) when self.ndim == 2, we assume axis=0
423 func = missing.get_fill_func(method, ndim=self.ndim)
424
425 npvalues = self._ndarray.T
426 if copy:
427 npvalues = npvalues.copy()
428 elif self._readonly:
429 raise ValueError("Cannot modify read-only array")
430 func(npvalues, limit=limit, limit_area=limit_area, mask=mask.T)
431 npvalues = npvalues.T
432
433 if copy:
434 new_values = self._from_backing_data(npvalues)
435 else:
436 new_values = self
437
438 elif copy:
439 new_values = self.copy()
440 else:
441 new_values = self
442 return new_values
443
444 def fillna(self, value, limit: int | None = None, copy: bool = True) -> Self:
445 """
446 Fill NA/NaN values using the specified method.
447
448 Parameters
449 ----------
450 value : scalar, array-like
451 If a scalar value is passed it is used to fill all missing values.
452 Alternatively, an array-like 'value' can be given. It's expected
453 that the array-like have the same length as 'self'.
454 limit : int, default None
455 The maximum number of entries along the entire axis where NaNs will be
456 filled.
457
458 Returns
459 -------
460 ExtensionArray
461 With NA/NaN filled.
462 """
463 mask = self.isna()
464 if limit is not None and limit < len(self):
465 # mypy doesn't like that mask can be an EA which need not have `cumsum`
466 modify = mask.cumsum() > limit # type: ignore[union-attr]
467 if modify.any():
468 # Only copy mask if necessary
469 mask = mask.copy()
470 mask[modify] = False
471 # error: Argument 2 to "check_value_size" has incompatible type
472 # "ExtensionArray"; expected "ndarray"
473 value = missing.check_value_size(
474 value,
475 mask, # type: ignore[arg-type]
476 len(self),
477 )
478
479 if mask.any():
480 # fill with value
481 if copy:
482 new_values = self.copy()
483 else:
484 new_values = self[:]
485 new_values[mask] = value
486 else:
487 # We validate the fill_value even if there is nothing to fill
488 self._validate_setitem_value(value)
489
490 if not copy:
491 new_values = self[:]
492 else:
493 new_values = self.copy()
494 return new_values
495
496 # ------------------------------------------------------------------------
497 # Reductions
498
499 def _wrap_reduction_result(self, axis: AxisInt | None, result) -> Any:
500 if axis is None or self.ndim == 1:
501 return self._box_func(result)
502 return self._from_backing_data(result)
503
504 # ------------------------------------------------------------------------
505 # __array_function__ methods
506
507 def _putmask(self, mask: npt.NDArray[np.bool_], value) -> None:
508 """
509 Analogue to np.putmask(self, mask, value)
510
511 Parameters
512 ----------
513 mask : np.ndarray[bool]
514 value : scalar or listlike
515
516 Raises
517 ------
518 TypeError
519 If value cannot be cast to self.dtype.
520 """
521 if self._readonly:
522 raise ValueError("Cannot modify read-only array")
523 value = self._validate_setitem_value(value)
524
525 np.putmask(self._ndarray, mask, value)
526
527 def _where(self: Self, mask: npt.NDArray[np.bool_], value) -> Self:
528 """
529 Analogue to np.where(mask, self, value)
530
531 Parameters
532 ----------
533 mask : np.ndarray[bool]
534 value : scalar or listlike
535
536 Raises
537 ------
538 TypeError
539 If value cannot be cast to self.dtype.
540 """
541 value = self._validate_setitem_value(value)
542
543 res_values = np.where(mask, self._ndarray, value)
544 if res_values.dtype != self._ndarray.dtype:
545 raise AssertionError(
546 # GH#56410
547 "Something has gone wrong, please report a bug at "
548 "github.com/pandas-dev/pandas/"
549 )
550 return self._from_backing_data(res_values)
551
552 # ------------------------------------------------------------------------
553 # Index compat methods
554
555 def insert(self, loc: int, item) -> Self:
556 """
557 Make new ExtensionArray inserting new item at location. Follows
558 Python list.append semantics for negative values.
559
560 Parameters
561 ----------
562 loc : int
563 item : object
564
565 Returns
566 -------
567 type(self)
568 """
569 loc = validate_insert_loc(loc, len(self))
570
571 code = self._validate_scalar(item)
572
573 new_vals = np.concatenate(
574 (
575 self._ndarray[:loc],
576 np.asarray([code], dtype=self._ndarray.dtype),
577 self._ndarray[loc:],
578 )
579 )
580 return self._from_backing_data(new_vals)
581
582 # ------------------------------------------------------------------------
583 # Additional array methods
584 # These are not part of the EA API, but we implement them because
585 # pandas assumes they're there.
586
587 def value_counts(self, dropna: bool = True) -> Series:
588 """
589 Return a Series containing counts of unique values.
590
591 Parameters
592 ----------
593 dropna : bool, default True
594 Don't include counts of NA values.
595
596 Returns
597 -------
598 Series
599 """
600 if self.ndim != 1:
601 raise NotImplementedError
602
603 from pandas import (
604 Index,
605 Series,
606 )
607
608 if dropna:
609 # error: Invalid index type
610 values = self[~self.isna()]._ndarray # type: ignore[index]
611 else:
612 values = self._ndarray
613
614 result = value_counts(values, sort=False, dropna=dropna)
615
616 index_arr = self._from_backing_data(np.asarray(result.index._data))
617 index = Index(index_arr, name=result.index.name, copy=False)
618 return Series(result._values, index=index, name=result.name, copy=False)
619
620 def _quantile(
621 self,
622 qs: npt.NDArray[np.float64],
623 interpolation: str,
624 ) -> Self:
625 # TODO: disable for Categorical if not ordered?
626
627 mask = np.asarray(self.isna())
628 arr = self._ndarray
629 fill_value = self._internal_fill_value
630
631 res_values = quantile_with_mask(arr, mask, fill_value, qs, interpolation)
632 if res_values.dtype == self._ndarray.dtype:
633 return self._from_backing_data(res_values)
634 else:
635 # e.g. test_quantile_empty we are empty integer dtype and res_values
636 # has floating dtype
637 # TODO: technically __init__ isn't defined here.
638 # Should we raise NotImplementedError and handle this on NumpyEA?
639 return type(self)(res_values) # type: ignore[call-arg]
640
641 # ------------------------------------------------------------------------
642 # numpy-like methods
643
644 @classmethod
645 def _empty(cls, shape: Shape, dtype: ExtensionDtype) -> Self:
646 """
647 Analogous to np.empty(shape, dtype=dtype)
648
649 Parameters
650 ----------
651 shape : tuple[int]
652 dtype : ExtensionDtype
653 """
654 # The base implementation uses a naive approach to find the dtype
655 # for the backing ndarray
656 arr = cls._from_sequence([], dtype=dtype)
657 backing = np.empty(shape, dtype=arr._ndarray.dtype)
658 return arr._from_backing_data(backing)