Skip to content

Commit 1440eee

Browse files
authored
Merge pull request numpy#26215 from mtsokol/one-copy-__array__
API: Enforce one copy for ``__array__`` when ``copy=True``
2 parents 509985c + 3549902 commit 1440eee

File tree

9 files changed

+103
-30
lines changed

9 files changed

+103
-30
lines changed

numpy/_core/src/multiarray/array_coercion.c

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ enum _dtype_discovery_flags {
9999
DISCOVER_TUPLES_AS_ELEMENTS = 1 << 4,
100100
MAX_DIMS_WAS_REACHED = 1 << 5,
101101
DESCRIPTOR_WAS_SET = 1 << 6,
102+
COPY_WAS_CREATED_BY__ARRAY__ = 1 << 7,
102103
};
103104

104105

@@ -1027,15 +1028,19 @@ PyArray_DiscoverDTypeAndShape_Recursive(
10271028
/* __array__ may be passed the requested descriptor if provided */
10281029
requested_descr = *out_descr;
10291030
}
1031+
int was_copied_by__array__ = 0;
10301032
arr = (PyArrayObject *)_array_from_array_like(obj,
1031-
requested_descr, 0, NULL, copy);
1033+
requested_descr, 0, NULL, copy, &was_copied_by__array__);
10321034
if (arr == NULL) {
10331035
return -1;
10341036
}
10351037
else if (arr == (PyArrayObject *)Py_NotImplemented) {
10361038
Py_DECREF(arr);
10371039
arr = NULL;
10381040
}
1041+
if (was_copied_by__array__ == 1) {
1042+
*flags |= COPY_WAS_CREATED_BY__ARRAY__;
1043+
}
10391044
}
10401045
if (arr != NULL) {
10411046
/*
@@ -1170,6 +1175,15 @@ PyArray_DiscoverDTypeAndShape_Recursive(
11701175
return -1;
11711176
}
11721177

1178+
/*
1179+
* For a sequence we need to make a copy of the final aggreate anyway.
1180+
* There's no need to pass explicit `copy=True`, so we switch
1181+
* to `copy=None` (copy if needed).
1182+
*/
1183+
if (copy == 1) {
1184+
copy = -1;
1185+
}
1186+
11731187
/* Recursive call for each sequence item */
11741188
for (Py_ssize_t i = 0; i < size; i++) {
11751189
max_dims = PyArray_DiscoverDTypeAndShape_Recursive(
@@ -1217,6 +1231,8 @@ PyArray_DiscoverDTypeAndShape_Recursive(
12171231
* to choose a default.
12181232
* @param copy Specifies the copy behavior. -1 is corresponds to copy=None,
12191233
* 0 to copy=False, and 1 to copy=True in the Python API.
1234+
* @param was_copied_by__array__ Set to 1 if it can be assumed that a copy was
1235+
* made by implementor.
12201236
* @return dimensions of the discovered object or -1 on error.
12211237
* WARNING: If (and only if) the output is a single array, the ndim
12221238
* returned _can_ exceed the maximum allowed number of dimensions.
@@ -1229,7 +1245,7 @@ PyArray_DiscoverDTypeAndShape(
12291245
npy_intp out_shape[NPY_MAXDIMS],
12301246
coercion_cache_obj **coercion_cache,
12311247
PyArray_DTypeMeta *fixed_DType, PyArray_Descr *requested_descr,
1232-
PyArray_Descr **out_descr, int copy)
1248+
PyArray_Descr **out_descr, int copy, int *was_copied_by__array__)
12331249
{
12341250
coercion_cache_obj **coercion_cache_head = coercion_cache;
12351251
*coercion_cache = NULL;
@@ -1282,6 +1298,10 @@ PyArray_DiscoverDTypeAndShape(
12821298
goto fail;
12831299
}
12841300

1301+
if (was_copied_by__array__ != NULL && flags & COPY_WAS_CREATED_BY__ARRAY__) {
1302+
*was_copied_by__array__ = 1;
1303+
}
1304+
12851305
if (NPY_UNLIKELY(flags & FOUND_RAGGED_ARRAY)) {
12861306
/*
12871307
* If max-dims was reached and the dimensions reduced, this is ragged.
@@ -1396,7 +1416,7 @@ _discover_array_parameters(PyObject *NPY_UNUSED(self),
13961416
int ndim = PyArray_DiscoverDTypeAndShape(
13971417
obj, NPY_MAXDIMS, shape,
13981418
&coercion_cache,
1399-
dt_info.dtype, dt_info.descr, (PyArray_Descr **)&out_dtype, 0);
1419+
dt_info.dtype, dt_info.descr, (PyArray_Descr **)&out_dtype, 0, NULL);
14001420
Py_XDECREF(dt_info.dtype);
14011421
Py_XDECREF(dt_info.descr);
14021422
if (ndim < 0) {

numpy/_core/src/multiarray/array_coercion.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ PyArray_DiscoverDTypeAndShape(
4040
npy_intp out_shape[NPY_MAXDIMS],
4141
coercion_cache_obj **coercion_cache,
4242
PyArray_DTypeMeta *fixed_DType, PyArray_Descr *requested_descr,
43-
PyArray_Descr **out_descr, int copy);
43+
PyArray_Descr **out_descr, int copy, int *was_copied_by__array__);
4444

4545
NPY_NO_EXPORT PyObject *
4646
_discover_array_parameters(PyObject *NPY_UNUSED(self),

numpy/_core/src/multiarray/arrayobject.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ PyArray_CopyObject(PyArrayObject *dest, PyObject *src_object)
251251
*/
252252
ndim = PyArray_DiscoverDTypeAndShape(src_object,
253253
PyArray_NDIM(dest), dims, &cache,
254-
NPY_DTYPE(PyArray_DESCR(dest)), PyArray_DESCR(dest), &dtype, 1);
254+
NPY_DTYPE(PyArray_DESCR(dest)), PyArray_DESCR(dest), &dtype, 1, NULL);
255255
if (ndim < 0) {
256256
return -1;
257257
}

numpy/_core/src/multiarray/common.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ PyArray_DTypeFromObject(PyObject *obj, int maxdims, PyArray_Descr **out_dtype)
119119
int ndim;
120120

121121
ndim = PyArray_DiscoverDTypeAndShape(
122-
obj, maxdims, shape, &cache, NULL, NULL, out_dtype, 1);
122+
obj, maxdims, shape, &cache, NULL, NULL, out_dtype, 1, NULL);
123123
if (ndim < 0) {
124124
return -1;
125125
}

numpy/_core/src/multiarray/ctors.c

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,8 @@ _array_from_buffer_3118(PyObject *memoryview)
14291429
* @param writeable whether the result must be writeable.
14301430
* @param context Unused parameter, must be NULL (should be removed later).
14311431
* @param copy Specifies the copy behavior.
1432+
* @param was_copied_by__array__ Set to 1 if it can be assumed that a copy
1433+
* was made by implementor.
14321434
*
14331435
* @returns The array object, Py_NotImplemented if op is not array-like,
14341436
* or NULL with an error set. (A new reference to Py_NotImplemented
@@ -1437,7 +1439,7 @@ _array_from_buffer_3118(PyObject *memoryview)
14371439
NPY_NO_EXPORT PyObject *
14381440
_array_from_array_like(PyObject *op,
14391441
PyArray_Descr *requested_dtype, npy_bool writeable, PyObject *context,
1440-
int copy) {
1442+
int copy, int *was_copied_by__array__) {
14411443
PyObject* tmp;
14421444

14431445
/*
@@ -1485,7 +1487,8 @@ _array_from_array_like(PyObject *op,
14851487
}
14861488

14871489
if (tmp == Py_NotImplemented) {
1488-
tmp = PyArray_FromArrayAttr_int(op, requested_dtype, copy);
1490+
tmp = PyArray_FromArrayAttr_int(
1491+
op, requested_dtype, copy, was_copied_by__array__);
14891492
if (tmp == NULL) {
14901493
return NULL;
14911494
}
@@ -1572,13 +1575,17 @@ PyArray_FromAny_int(PyObject *op, PyArray_Descr *in_descr,
15721575

15731576
// Default is copy = None
15741577
int copy = -1;
1578+
int was_copied_by__array__ = 0;
15751579

15761580
if (flags & NPY_ARRAY_ENSURENOCOPY) {
15771581
copy = 0;
1582+
} else if (flags & NPY_ARRAY_ENSURECOPY) {
1583+
copy = 1;
15781584
}
15791585

15801586
ndim = PyArray_DiscoverDTypeAndShape(
1581-
op, NPY_MAXDIMS, dims, &cache, in_DType, in_descr, &dtype, copy);
1587+
op, NPY_MAXDIMS, dims, &cache, in_DType, in_descr, &dtype,
1588+
copy, &was_copied_by__array__);
15821589

15831590
if (ndim < 0) {
15841591
return NULL;
@@ -1615,6 +1622,9 @@ PyArray_FromAny_int(PyObject *op, PyArray_Descr *in_descr,
16151622
assert(cache->converted_obj == op);
16161623
arr = (PyArrayObject *)(cache->arr_or_sequence);
16171624
/* we may need to cast or assert flags (e.g. copy) */
1625+
if (was_copied_by__array__ == 1) {
1626+
flags = flags & ~NPY_ARRAY_ENSURECOPY;
1627+
}
16181628
PyObject *res = PyArray_FromArray(arr, dtype, flags);
16191629
npy_unlink_coercion_cache(cache);
16201630
return res;
@@ -1937,7 +1947,7 @@ PyArray_FromArray(PyArrayObject *arr, PyArray_Descr *newtype, int flags)
19371947
}
19381948

19391949
if (copy) {
1940-
if (flags & NPY_ARRAY_ENSURENOCOPY ) {
1950+
if (flags & NPY_ARRAY_ENSURENOCOPY) {
19411951
PyErr_SetString(PyExc_ValueError, npy_no_copy_err_msg);
19421952
Py_DECREF(newtype);
19431953
return NULL;
@@ -2497,12 +2507,14 @@ check_or_clear_and_warn_error_if_due_to_copy_kwarg(PyObject *kwnames)
24972507
* NOTE: For copy == -1 it passes `op.__array__(copy=None)`,
24982508
* for copy == 0, `op.__array__(copy=False)`, and
24992509
* for copy == 1, `op.__array__(copy=True).
2510+
* @param was_copied_by__array__ Set to 1 if it can be assumed that a copy
2511+
* was made by implementor.
25002512
* @returns NotImplemented if `__array__` is not defined or a NumPy array
25012513
* (or subclass). On error, return NULL.
25022514
*/
25032515
NPY_NO_EXPORT PyObject *
2504-
PyArray_FromArrayAttr_int(
2505-
PyObject *op, PyArray_Descr *descr, int copy)
2516+
PyArray_FromArrayAttr_int(PyObject *op, PyArray_Descr *descr, int copy,
2517+
int *was_copied_by__array__)
25062518
{
25072519
PyObject *new;
25082520
PyObject *array_meth;
@@ -2589,10 +2601,13 @@ PyArray_FromArrayAttr_int(
25892601
Py_DECREF(new);
25902602
return NULL;
25912603
}
2592-
if (must_copy_but_copy_kwarg_unimplemented) {
2593-
/* TODO: As of NumPy 2.0 this path is only reachable by C-API. */
2594-
Py_SETREF(new, PyArray_NewCopy((PyArrayObject *)new, NPY_KEEPORDER));
2604+
/* TODO: Remove was_copied_by__array__ argument */
2605+
if (was_copied_by__array__ != NULL && copy == 1 &&
2606+
must_copy_but_copy_kwarg_unimplemented == 0) {
2607+
/* We can assume that a copy was made */
2608+
*was_copied_by__array__ = 1;
25952609
}
2610+
25962611
return new;
25972612
}
25982613

@@ -2607,7 +2622,7 @@ PyArray_FromArrayAttr(PyObject *op, PyArray_Descr *typecode, PyObject *context)
26072622
return NULL;
26082623
}
26092624

2610-
return PyArray_FromArrayAttr_int(op, typecode, 0);
2625+
return PyArray_FromArrayAttr_int(op, typecode, 0, NULL);
26112626
}
26122627

26132628

numpy/_core/src/multiarray/ctors.h

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ PyArray_New(
5454
NPY_NO_EXPORT PyObject *
5555
_array_from_array_like(PyObject *op,
5656
PyArray_Descr *requested_dtype, npy_bool writeable, PyObject *context,
57-
int copy);
57+
int copy, int *was_copied_by__array__);
5858

5959
NPY_NO_EXPORT PyObject *
6060
PyArray_FromAny_int(PyObject *op, PyArray_Descr *in_descr,
@@ -84,8 +84,8 @@ NPY_NO_EXPORT PyObject *
8484
PyArray_FromInterface(PyObject *input);
8585

8686
NPY_NO_EXPORT PyObject *
87-
PyArray_FromArrayAttr_int(
88-
PyObject *op, PyArray_Descr *descr, int copy);
87+
PyArray_FromArrayAttr_int(PyObject *op, PyArray_Descr *descr, int copy,
88+
int *was_copied_by__array__);
8989

9090
NPY_NO_EXPORT PyObject *
9191
PyArray_FromArrayAttr(PyObject *op, PyArray_Descr *typecode,

numpy/_core/tests/test_array_coercion.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ def __init__(self, a):
5454
self.a = a
5555

5656
def __array__(self, dtype=None, copy=None):
57-
return self.a
57+
if dtype is None:
58+
return self.a
59+
return self.a.astype(dtype)
5860

5961
yield param(ArrayDunder, id="__array__")
6062

numpy/_core/tests/test_multiarray.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8452,10 +8452,9 @@ def __array__(self, dtype=None, copy=None):
84528452
for copy in self.true_vals:
84538453
res = np.array(arr, copy=copy)
84548454
assert_array_equal(res, base_arr)
8455-
# An additional copy is currently forced by numpy in this case,
8456-
# you could argue, numpy does not trust the ArrayLike. This
8457-
# may be open for change:
8458-
assert res is not base_arr
8455+
# An additional copy is no longer forced by NumPy in this case.
8456+
# NumPy trusts the ArrayLike made a copy:
8457+
assert res is base_arr
84598458

84608459
for copy in self.if_needed_vals + self.false_vals:
84618460
res = np.array(arr, copy=copy)
@@ -8488,9 +8487,11 @@ def __array__(self, dtype=None):
84888487
assert_array_equal(arr, base_arr)
84898488
assert arr is base_arr
84908489

8491-
# As of NumPy 2, explicitly passing copy=True does not trigger passing
8492-
# it to __array__ (deprecation warning is not triggered).
8493-
arr = np.array(a, copy=True)
8490+
# As of NumPy 2.1, explicitly passing copy=True does trigger passing
8491+
# it to __array__ (deprecation warning is triggered).
8492+
with pytest.warns(DeprecationWarning,
8493+
match="__array__.*must implement.*'copy'"):
8494+
arr = np.array(a, copy=True)
84948495
assert_array_equal(arr, base_arr)
84958496
assert arr is not base_arr
84968497

@@ -8501,10 +8502,45 @@ def __array__(self, dtype=None):
85018502
match=r"Unable to avoid copy(.|\n)*numpy_2_0_migration_guide.html"):
85028503
np.array(a, copy=False)
85038504

8505+
def test___array__copy_once(self):
8506+
size = 100
8507+
base_arr = np.zeros((size, size))
8508+
copy_arr = np.zeros((size, size))
8509+
8510+
class ArrayRandom:
8511+
def __init__(self):
8512+
self.true_passed = False
8513+
8514+
def __array__(self, dtype=None, copy=None):
8515+
if copy:
8516+
self.true_passed = True
8517+
return copy_arr
8518+
else:
8519+
return base_arr
8520+
8521+
arr_random = ArrayRandom()
8522+
first_copy = np.array(arr_random, copy=True)
8523+
assert arr_random.true_passed
8524+
assert first_copy is copy_arr
8525+
8526+
arr_random = ArrayRandom()
8527+
no_copy = np.array(arr_random, copy=False)
8528+
assert not arr_random.true_passed
8529+
assert no_copy is base_arr
8530+
8531+
arr_random = ArrayRandom()
8532+
_ = np.array([arr_random], copy=True)
8533+
assert not arr_random.true_passed
8534+
8535+
arr_random = ArrayRandom()
8536+
second_copy = np.array(arr_random, copy=True, order="F")
8537+
assert arr_random.true_passed
8538+
assert second_copy is not copy_arr
8539+
85048540
@pytest.mark.skipif(not HAS_REFCOUNT, reason="Python lacks refcounts")
85058541
def test__array__reference_leak(self):
85068542
class NotAnArray:
8507-
def __array__(self):
8543+
def __array__(self, dtype=None, copy=None):
85088544
raise NotImplementedError()
85098545

85108546
x = NotAnArray()

numpy/_core/tests/test_protocols.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ def test_array_called():
3535
class Wrapper:
3636
val = '0' * 100
3737

38-
def __array__(self, result=None, copy=None):
39-
return np.array([self.val], dtype=object)
38+
def __array__(self, dtype=None, copy=None):
39+
return np.array([self.val], dtype=dtype, copy=copy)
4040

4141

4242
wrapped = Wrapper()

0 commit comments

Comments
 (0)