Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/test-gpu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ jobs:
run: nvidia-smi

- name: Install yq # https://cirun.slack.com/archives/C09SNDRB3A8/p1766512487317849?thread_ts=1766512112.938459&cid=C09SNDRB3A8
# https://github.com/mikefarah/yq/issues/2592 forces version pin
run: |
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo wget -qO /usr/local/bin/yq http://github.com/mikefarah/yq/releases/download/v4.49.2/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq

- name: Extract max Python version from classifiers
Expand Down
8 changes: 8 additions & 0 deletions src/anndata/_core/anndata.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,14 @@ def X(self) -> XDataType | None:
def _handle_view_X_cow(self, value: XDataType | None):
if self._is_view:
if settings.copy_on_write_X:
if np.isscalar(value) and value is not None:
msg = "The ability to set X with a scalar value will be removed in the future. Initializing as an `np.array` with the shape of the current view."
warnings.warn(msg, FutureWarning, stacklevel=2)
value = np.full(self.shape, fill_value=value)
if hasattr(value, "shape") and value.shape != self.shape:
msg = "Automatic reshaping when setting X will be removed in the future."
warnings.warn(msg, FutureWarning, stacklevel=2)
value = value.reshape(self.shape)
msg = "Setting element `.X` of view, initializing view as actual."
warnings.warn(msg, ImplicitModificationWarning, stacklevel=2)
new = self._mutated_copy(X=value)
Expand Down
66 changes: 54 additions & 12 deletions tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from contextlib import nullcontext
from contextlib import ExitStack, nullcontext
from copy import deepcopy
from functools import partial
from importlib.metadata import version
Expand All @@ -24,6 +24,7 @@
SparseCSRArrayView,
SparseCSRMatrixView,
)
from anndata._warnings import ImplicitModificationWarning
from anndata.compat import NUMPY_2, CSArray, CupyCSCMatrix, DaskArray
from anndata.tests.helpers import (
BASE_MATRIX_PARAMS,
Expand Down Expand Up @@ -119,21 +120,43 @@ def mapping_name(request):
return request.param


@pytest.fixture(params=[True, False], ids=["CoW", "update"])
def copy_on_write_X(request):
return request.param


# ------------------------------------------------------------------------------
# The test functions
# ------------------------------------------------------------------------------


def test_views():
def test_views(*, copy_on_write_X: bool):
ad.settings.copy_on_write_X = copy_on_write_X
X = np.array(X_list, dtype="int32")
adata = ad.AnnData(X, obs=obs_dict, var=var_dict, uns=uns_dict)

assert adata[:, 0].is_view
assert adata[:, 0].X.tolist() == np.reshape([1, 4, 7], (3, 1)).tolist()

adata[:2, 0].X = [0, 0]

assert adata[:, 0].X.tolist() == np.reshape([0, 0, 7], (3, 1)).tolist()
ctx_managers = (
(
pytest.warns(
ImplicitModificationWarning, match=r"initializing view as actual"
),
pytest.warns(FutureWarning, match=r"Automatic reshaping"),
)
if copy_on_write_X
else ()
)
with ExitStack() as stack:
for mgr in ctx_managers:
stack.enter_context(mgr)
adata[:2, 0].X = [0, 0]

assert (
adata[:, 0].X.tolist()
== np.reshape([1, 4, 7] if copy_on_write_X else [0, 0, 7], (3, 1)).tolist()
)

adata_subset = adata[:2, [0, 1]]

Expand Down Expand Up @@ -385,18 +408,37 @@ def test_not_set_subset_X_dask(matrix_type_no_gpu, subset_func):


@IGNORE_SPARSE_EFFICIENCY_WARNING
def test_set_scalar_subset_X(matrix_type, subset_func):
def test_set_scalar_subset_X(matrix_type, subset_func, *, copy_on_write_X: bool):
ad.settings.copy_on_write_X = copy_on_write_X
adata = ad.AnnData(matrix_type(np.zeros((10, 10))))
orig_X_val = adata.X.copy()
subset_idx = subset_func(adata.obs_names)

adata_subset = adata[subset_idx, :]

adata_subset.X = 1

assert adata_subset.is_view
assert np.all(asarray(adata[subset_idx, :].X) == 1)
if isinstance(adata.X, CupyCSCMatrix):
ctx_managers = (
(
pytest.warns(
ImplicitModificationWarning, match=r"initializing view as actual"
),
pytest.warns(
FutureWarning, match=r"The ability to set X with a scalar value"
),
)
if copy_on_write_X
else ()
)
with ExitStack() as stack:
for mgr in ctx_managers:
stack.enter_context(mgr)
adata_subset.X = 1

assert adata_subset.is_view != copy_on_write_X
assert np.all(
asarray((adata_subset if copy_on_write_X else adata[subset_idx, :]).X) == 1
)
if copy_on_write_X:
assert (asarray(orig_X_val) == asarray(adata.X)).all()
elif isinstance(adata.X, CupyCSCMatrix):
# Comparison broken for CSC matrices
# https://github.com/cupy/cupy/issues/7757
assert asarray(orig_X_val.tocsr() != adata.X.tocsr()).sum() == mul(
Expand Down
Loading