diff --git a/.github/workflows/test-gpu.yml b/.github/workflows/test-gpu.yml index 11c3b99fc..de2c2466d 100644 --- a/.github/workflows/test-gpu.yml +++ b/.github/workflows/test-gpu.yml @@ -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 diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 4cfe4eddd..8e4c676c3 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -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) diff --git a/tests/test_views.py b/tests/test_views.py index 83bc58748..143053ca2 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -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 @@ -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, @@ -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]] @@ -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(