From 0c3dec7a63fee6fc2dcf88c00d3d0fcd97c425f5 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:20:48 +0100 Subject: [PATCH 01/23] chore: add `anndata.settings.copy_on_write_X` --- src/anndata/_core/anndata.py | 138 +++++++++++++++++++---------------- src/anndata/_core/views.py | 9 ++- src/anndata/_settings.py | 9 +++ tests/test_x.py | 38 +++++++--- 4 files changed, 117 insertions(+), 77 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index f582c5dfe..feb5dd973 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -208,6 +208,7 @@ class AnnData(metaclass=utils.DeprecationMixinMeta): # noqa: PLW1641 _adata_ref: AnnData | None _oidx: _Index1DNorm | None _vidx: _Index1DNorm | None + _X: None | _XDataType = None @old_positionals( "obsm", @@ -586,10 +587,13 @@ def X(self) -> _XDataType | None: elif self.is_view and self._adata_ref.X is None: X = None elif self.is_view: - X = as_view( - _subset(self._adata_ref.X, (self._oidx, self._vidx)), - ElementRef(self, "X"), - ) + if self._X is not None: + X = self._X + else: + X = as_view( + _subset(self._adata_ref.X, (self._oidx, self._vidx)), + ElementRef(self, "X"), + ) else: X = self._X return X @@ -602,7 +606,30 @@ def X(self) -> _XDataType | None: # return X @X.setter - def X(self, value: _XDataType | None): # noqa: PLR0912 + def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 + value = ( + coerce_array(value, name="X", allow_array_like=True) + if value is not None + else None + ) + can_set_direct_non_none = value is not None and ( + np.isscalar(value) + or (hasattr(value, "shape") and (self.shape == value.shape)) + or (self.n_vars == 1 and self.n_obs == len(value)) + or (self.n_obs == 1 and self.n_vars == len(value)) + ) + if not can_set_direct_non_none: + msg = f"Data matrix has wrong shape {value.shape}, need to be {self.shape}." + raise ValueError(msg) + if self._is_view: + if settings.copy_on_write_X: + msg = "Setting element `.X` of view, initializing view as actual." + warn(msg, ImplicitModificationWarning) + self._X = value + return None + else: + msg = "Setting element `.X` of view will obey copy-on-write semantics in the next minor release. Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." + warn(msg, FutureWarning) if value is None: if self.isbacked: msg = "Cannot currently remove data matrix from backed object." @@ -611,7 +638,6 @@ def X(self, value: _XDataType | None): # noqa: PLR0912 self._init_as_actual(self.copy()) self._X = None return - value = coerce_array(value, name="X", allow_array_like=True) # If indices are both arrays, we need to modify them # so we don’t set values like coordinates @@ -624,65 +650,51 @@ def X(self, value: _XDataType | None): # noqa: PLR0912 oidx, vidx = np.ix_(self._oidx, self._vidx) else: oidx, vidx = self._oidx, self._vidx - if ( - np.isscalar(value) - or (hasattr(value, "shape") and (self.shape == value.shape)) - or (self.n_vars == 1 and self.n_obs == len(value)) - or (self.n_obs == 1 and self.n_vars == len(value)) - ): - if not np.isscalar(value): - if self.is_view and any( - isinstance(idx, np.ndarray) - and len(np.unique(idx)) != len(idx.ravel()) - for idx in [oidx, vidx] - ): - msg = ( - "You are attempting to set `X` to a matrix on a view which has non-unique indices. " - "The resulting `adata.X` will likely not equal the value to which you set it. " - "To avoid this potential issue, please make a copy of the data first. " - "In the future, this operation will throw an error." - ) - warn(msg, FutureWarning) - if self.shape != value.shape: - # For assigning vector of values to 2d array or matrix - # Not necessary for row of 2d array - value = value.reshape(self.shape) - if self.isbacked: - if self.is_view: - X = self.file["X"] - if isinstance(X, h5py.Group): - msg = "Cannot write to views of sparse backed files" - raise NotImplementedError(msg) - X[oidx, vidx] = value - else: - self._set_backed("X", value) - elif self.is_view: - if sparse.issparse(self._adata_ref._X) and isinstance( - value, np.ndarray - ): - if isinstance(self._adata_ref.X, CSArray): - memory_class = sparse.coo_array - else: - memory_class = sparse.coo_matrix - value = memory_class(value) - elif sparse.issparse(value) and isinstance( - self._adata_ref._X, np.ndarray - ): - msg = ( - "Trying to set a dense array with a sparse array on a view. " - "Densifying the sparse array. " - "This may incur excessive memory usage" - ) - warn(msg, UserWarning) - value = value.toarray() - msg = "Modifying `X` on a view results in data being overridden" - warn(msg, ImplicitModificationWarning) - self._adata_ref._X[oidx, vidx] = value + if not np.isscalar(value): + if self.is_view and any( + isinstance(idx, np.ndarray) and len(np.unique(idx)) != len(idx.ravel()) + for idx in [oidx, vidx] + ): + msg = ( + "You are attempting to set `X` to a matrix on a view which has non-unique indices. " + "The resulting `adata.X` will likely not equal the value to which you set it. " + "To avoid this potential issue, please make a copy of the data first. " + "In the future, this operation will throw an error." + ) + warn(msg, FutureWarning) + if self.shape != value.shape: + # For assigning vector of values to 2d array or matrix + # Not necessary for row of 2d array + value = value.reshape(self.shape) + if self.isbacked: + if self.is_view: + X = self.file["X"] + if isinstance(X, h5py.Group): + msg = "Cannot write to views of sparse backed files" + raise NotImplementedError(msg) + X[oidx, vidx] = value else: - self._X = value + self._set_backed("X", value) + elif self.is_view: + if sparse.issparse(self._adata_ref._X) and isinstance(value, np.ndarray): + if isinstance(self._adata_ref.X, CSArray): + memory_class = sparse.coo_array + else: + memory_class = sparse.coo_matrix + value = memory_class(value) + elif sparse.issparse(value) and isinstance(self._adata_ref._X, np.ndarray): + msg = ( + "Trying to set a dense array with a sparse array on a view. " + "Densifying the sparse array. " + "This may incur excessive memory usage" + ) + warn(msg, UserWarning) + value = value.toarray() + msg = "Modifying `X` on a view results in data being overridden" + warn(msg, ImplicitModificationWarning) + self._adata_ref._X[oidx, vidx] = value else: - msg = f"Data matrix has wrong shape {value.shape}, need to be {self.shape}." - raise ValueError(msg) + self._X = value @X.deleter def X(self): diff --git a/src/anndata/_core/views.py b/src/anndata/_core/views.py index 95054139f..36c764650 100644 --- a/src/anndata/_core/views.py +++ b/src/anndata/_core/views.py @@ -57,9 +57,12 @@ def view_update(adata_view: AnnData, attr_name: str, keys: tuple[str, ...]): `adata.attr[key1][key2][keyn]...` """ new = adata_view.copy() - attr = getattr(new, attr_name) - container = reduce(lambda d, k: d[k], keys, attr) - yield container + if attr_name == "X": + yield new + else: + attr = getattr(new, attr_name) + container = reduce(lambda d, k: d[k], keys, attr) + yield container adata_view._init_as_actual(new) diff --git a/src/anndata/_settings.py b/src/anndata/_settings.py index 1b8268898..83515e54d 100644 --- a/src/anndata/_settings.py +++ b/src/anndata/_settings.py @@ -526,5 +526,14 @@ def validate_sparse_settings(val: Any, settings: SettingsManager) -> None: ) +settings.register( + "copy_on_write_X", + default_value=False, + description="Whether to copy-on-write X. Currently `my_adata_view[subset].X = value` will write back to the original AnnData object at the `subset` location. `X` is the only element where this behavior is implemented though.", + validate=validate_bool, + get_from_env=check_and_get_bool, +) + + ################################################################################## ################################################################################## diff --git a/tests/test_x.py b/tests/test_x.py index d7da59a0c..48b0b175b 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -50,17 +50,33 @@ def test_repeat_indices_view(): @pytest.mark.parametrize("orig_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("new_array_type", UNLABELLED_ARRAY_TYPES) -def test_setter_view(orig_array_type, new_array_type): - adata = gen_adata((10, 10), X_type=orig_array_type) - orig_X = adata.X - to_assign = new_array_type(np.ones((9, 9))) - if isinstance(orig_X, np.ndarray) and sparse.issparse(to_assign): - # https://github.com/scverse/anndata/issues/500 - pytest.xfail("Cannot set a dense array with a sparse array") - view = adata[:9, :9] - view.X = to_assign - np.testing.assert_equal(asarray(view.X), np.ones((9, 9))) - assert isinstance(view.X, type(orig_X)) +@pytest.mark.parametrize("copy_on_write_X", [True, False], ids=["CoW", "update"]) +def test_setter_view(orig_array_type, new_array_type, *, copy_on_write_X: bool): + with ad.settings.override(copy_on_write_X=copy_on_write_X): + adata = gen_adata((10, 10), X_type=orig_array_type) + orig_X = adata.X + expected_X = asarray(orig_X.copy()) + to_assign = new_array_type(np.ones((9, 9))) + if not copy_on_write_X: + expected_X[:9, :9] = asarray(to_assign) + if ( + not copy_on_write_X + and isinstance(orig_X, np.ndarray) + and sparse.issparse(to_assign) + ): + # https://github.com/scverse/anndata/issues/500 + pytest.xfail("Cannot set a dense array with a sparse array") + view = adata[:9, :9] + with pytest.warns( + ImplicitModificationWarning if copy_on_write_X else FutureWarning, + match=r"initializing view as actual" + if copy_on_write_X + else r"will obey copy-on-write semantics", + ): + view.X = to_assign + assert_equal(view.X, to_assign) + assert isinstance(view.X, type(to_assign) if copy_on_write_X else type(orig_X)) + assert_equal(adata.X, expected_X) ############################### From 2679833759c3f59d1277491bd5fed75a676d7365 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:36:42 +0100 Subject: [PATCH 02/23] fix: `copy` semantics --- src/anndata/_core/anndata.py | 26 +++++++++++++++----------- tests/test_x.py | 2 ++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index feb5dd973..e6851cfcf 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -625,7 +625,9 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 if settings.copy_on_write_X: msg = "Setting element `.X` of view, initializing view as actual." warn(msg, ImplicitModificationWarning) - self._X = value + new = self.copy() + new._X = value + self._init_as_actual(new) return None else: msg = "Setting element `.X` of view will obey copy-on-write semantics in the next minor release. Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." @@ -1522,16 +1524,18 @@ def to_memory(self, *, copy: bool = False) -> AnnData: def copy(self, filename: PathLike[str] | str | None = None) -> AnnData: """Full copy, optionally on disk.""" if not self.isbacked: - if self.is_view and self._has_X(): - # TODO: How do I unambiguously check if this is a copy? - # Subsetting this way means we don’t have to have a view type - # defined for the matrix, which is needed for some of the - # current distributed backend. Specifically Dask. - return self._mutated_copy( - X=_subset(self._adata_ref.X, (self._oidx, self._vidx)).copy() - ) - else: - return self._mutated_copy() + if self.is_view: + if self._X is not None: + return self._mutated_copy(X=self._X) + if self._has_X(): + # TODO: How do I unambiguously check if this is a copy? + # Subsetting this way means we don’t have to have a view type + # defined for the matrix, which is needed for some of the + # current distributed backend. Specifically Dask. + return self._mutated_copy( + X=_subset(self._adata_ref.X, (self._oidx, self._vidx)).copy() + ) + return self._mutated_copy() else: from ..io import read_h5ad, write_h5ad diff --git a/tests/test_x.py b/tests/test_x.py index 48b0b175b..1d61f70cd 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -77,6 +77,8 @@ def test_setter_view(orig_array_type, new_array_type, *, copy_on_write_X: bool): assert_equal(view.X, to_assign) assert isinstance(view.X, type(to_assign) if copy_on_write_X else type(orig_X)) assert_equal(adata.X, expected_X) + # If cow, then not a view and if not cow, it is a view + assert view.is_view != copy_on_write_X ############################### From dc61dcfd1220102ad8d2e71dab03ea83340287f6 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:38:48 +0100 Subject: [PATCH 03/23] fix: don't need to check `_X` for `None` because `view` can't have that --- src/anndata/_core/anndata.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index e6851cfcf..23fbd4cfc 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -587,13 +587,10 @@ def X(self) -> _XDataType | None: elif self.is_view and self._adata_ref.X is None: X = None elif self.is_view: - if self._X is not None: - X = self._X - else: - X = as_view( - _subset(self._adata_ref.X, (self._oidx, self._vidx)), - ElementRef(self, "X"), - ) + X = as_view( + _subset(self._adata_ref.X, (self._oidx, self._vidx)), + ElementRef(self, "X"), + ) else: X = self._X return X From b06bb87187300e4b70c1cc65a9d581570c9748bc Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:40:11 +0100 Subject: [PATCH 04/23] fix: remove `copy` check for `_X` --- src/anndata/_core/anndata.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 23fbd4cfc..6b9f41762 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -622,8 +622,7 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 if settings.copy_on_write_X: msg = "Setting element `.X` of view, initializing view as actual." warn(msg, ImplicitModificationWarning) - new = self.copy() - new._X = value + new = self._mutated_copy(X=value) self._init_as_actual(new) return None else: @@ -1521,17 +1520,14 @@ def to_memory(self, *, copy: bool = False) -> AnnData: def copy(self, filename: PathLike[str] | str | None = None) -> AnnData: """Full copy, optionally on disk.""" if not self.isbacked: - if self.is_view: - if self._X is not None: - return self._mutated_copy(X=self._X) - if self._has_X(): - # TODO: How do I unambiguously check if this is a copy? - # Subsetting this way means we don’t have to have a view type - # defined for the matrix, which is needed for some of the - # current distributed backend. Specifically Dask. - return self._mutated_copy( - X=_subset(self._adata_ref.X, (self._oidx, self._vidx)).copy() - ) + if self.is_view and self._has_X(): + # TODO: How do I unambiguously check if this is a copy? + # Subsetting this way means we don’t have to have a view type + # defined for the matrix, which is needed for some of the + # current distributed backend. Specifically Dask. + return self._mutated_copy( + X=_subset(self._adata_ref.X, (self._oidx, self._vidx)).copy() + ) return self._mutated_copy() else: from ..io import read_h5ad, write_h5ad From aec864fc374b6e5cff16a7dcd965f77ad00411d0 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:40:30 +0100 Subject: [PATCH 05/23] fix: remove unused `_X` declaration --- src/anndata/_core/anndata.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 6b9f41762..b88ed0d64 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -208,7 +208,6 @@ class AnnData(metaclass=utils.DeprecationMixinMeta): # noqa: PLW1641 _adata_ref: AnnData | None _oidx: _Index1DNorm | None _vidx: _Index1DNorm | None - _X: None | _XDataType = None @old_positionals( "obsm", From c7dac731dfbbbeb613130524b0def0ae5f942eb6 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:41:41 +0100 Subject: [PATCH 06/23] fix: logic around `None` --- src/anndata/_core/anndata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index b88ed0d64..beea2c406 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -608,13 +608,13 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 if value is not None else None ) - can_set_direct_non_none = value is not None and ( + can_set_direct = value is None or ( np.isscalar(value) or (hasattr(value, "shape") and (self.shape == value.shape)) or (self.n_vars == 1 and self.n_obs == len(value)) or (self.n_obs == 1 and self.n_vars == len(value)) ) - if not can_set_direct_non_none: + if not can_set_direct: msg = f"Data matrix has wrong shape {value.shape}, need to be {self.shape}." raise ValueError(msg) if self._is_view: From d76a96e91a030cf4a6b3653d15e5fc8fc18c9a9e Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:55:08 +0100 Subject: [PATCH 07/23] chore: more description --- src/anndata/_core/anndata.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index beea2c406..1e2a89bc1 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -625,7 +625,9 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 self._init_as_actual(new) return None else: - msg = "Setting element `.X` of view will obey copy-on-write semantics in the next minor release. Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." + msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release." + "In other words, this subset of your original `AnnData` will be copied-in-place and initialized with the value passed into this setter. " + "Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." warn(msg, FutureWarning) if value is None: if self.isbacked: From ecc5988a27687c1ca60ba0d082f561039d54c13e Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:56:41 +0100 Subject: [PATCH 08/23] chore: ignore warning --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 74e0a55cb..85f25737a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,6 +166,8 @@ filterwarnings_when_strict = [ "default:.*FixedLengthUTF32:zarr.core.dtype.common.UnstableSpecificationWarning", "default:Automatic shard shape inference is experimental", "default:Writing zarr v2:UserWarning", + # TODO: Remove in conjunction with or before https://github.com/scverse/anndata/pull/1707 + "default:will obey copy-on-write semantics:FutureWarning", ] python_files = [ "test_*.py" ] testpaths = [ From 0126b28bad61bcb7459d58ce90fb231149424b87 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 15:57:31 +0100 Subject: [PATCH 09/23] fix: add space --- src/anndata/_core/anndata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 1e2a89bc1..f525d9e7d 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -625,7 +625,7 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 self._init_as_actual(new) return None else: - msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release." + msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release. " "In other words, this subset of your original `AnnData` will be copied-in-place and initialized with the value passed into this setter. " "Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." warn(msg, FutureWarning) From 38c35ed879ab7dc07dbb0c9a4b49620c7c13f69a Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 16:06:23 +0100 Subject: [PATCH 10/23] fix: better comment name --- src/anndata/_core/anndata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index f525d9e7d..a0149c7f1 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -608,13 +608,13 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 if value is not None else None ) - can_set_direct = value is None or ( + can_set_direct_if_not_none = value is None or ( np.isscalar(value) or (hasattr(value, "shape") and (self.shape == value.shape)) or (self.n_vars == 1 and self.n_obs == len(value)) or (self.n_obs == 1 and self.n_vars == len(value)) ) - if not can_set_direct: + if not can_set_direct_if_not_none: msg = f"Data matrix has wrong shape {value.shape}, need to be {self.shape}." raise ValueError(msg) if self._is_view: From 29555629ff94690c83d59207f58bc2b761d6f07d Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 16:09:09 +0100 Subject: [PATCH 11/23] fix: clean up unused changes --- src/anndata/_core/anndata.py | 9 ++++----- src/anndata/_core/views.py | 9 +++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index a0149c7f1..7ff1d3a88 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -624,11 +624,10 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 new = self._mutated_copy(X=value) self._init_as_actual(new) return None - else: - msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release. " - "In other words, this subset of your original `AnnData` will be copied-in-place and initialized with the value passed into this setter. " - "Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." - warn(msg, FutureWarning) + msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release. " + "In other words, this subset of your original `AnnData` will be copied-in-place and initialized with the value passed into this setter. " + "Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." + warn(msg, FutureWarning) if value is None: if self.isbacked: msg = "Cannot currently remove data matrix from backed object." diff --git a/src/anndata/_core/views.py b/src/anndata/_core/views.py index 36c764650..95054139f 100644 --- a/src/anndata/_core/views.py +++ b/src/anndata/_core/views.py @@ -57,12 +57,9 @@ def view_update(adata_view: AnnData, attr_name: str, keys: tuple[str, ...]): `adata.attr[key1][key2][keyn]...` """ new = adata_view.copy() - if attr_name == "X": - yield new - else: - attr = getattr(new, attr_name) - container = reduce(lambda d, k: d[k], keys, attr) - yield container + attr = getattr(new, attr_name) + container = reduce(lambda d, k: d[k], keys, attr) + yield container adata_view._init_as_actual(new) From 4f444e3b9be8b99654efa9e519ec6fa9adec3f68 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 16:09:59 +0100 Subject: [PATCH 12/23] fix: hints --- src/anndata/_settings.pyi | 1 + 1 file changed, 1 insertion(+) diff --git a/src/anndata/_settings.pyi b/src/anndata/_settings.pyi index 2f2c71b0a..d55afdeda 100644 --- a/src/anndata/_settings.pyi +++ b/src/anndata/_settings.pyi @@ -39,6 +39,7 @@ class SettingsManager[T]: class _AnnDataSettingsManager(SettingsManager): remove_unused_categories: bool = True check_uniqueness: bool = True + copy_on_write_X: bool = False allow_write_nullable_strings: bool | None = None zarr_write_format: Literal[2, 3] = 2 use_sparse_array_on_read: bool = False From ab9f8401dee18e80f3d2a1f7ad254df5e56d066a Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 16:16:58 +0100 Subject: [PATCH 13/23] chore: relnote --- docs/release-notes/2327.chore.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/release-notes/2327.chore.md diff --git a/docs/release-notes/2327.chore.md b/docs/release-notes/2327.chore.md new file mode 100644 index 000000000..dc68905c0 --- /dev/null +++ b/docs/release-notes/2327.chore.md @@ -0,0 +1 @@ +Add {attr}`anndata.settings.copy_on_write_X` to allow for forward-compatibility with future copy-on-write behavior (release `0.13`). Currently, setting {attr}`~anndata.AnnData.X` on a view implicitly updates the object from which the view was created. {user}`ilan-gold` From e94d8b0ef81ee26f2be5afc3d761500a7eb40de5 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Wed, 4 Feb 2026 16:22:42 +0100 Subject: [PATCH 14/23] fix: warning ignoring --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 85f25737a..7b8fa06c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,7 +167,7 @@ filterwarnings_when_strict = [ "default:Automatic shard shape inference is experimental", "default:Writing zarr v2:UserWarning", # TODO: Remove in conjunction with or before https://github.com/scverse/anndata/pull/1707 - "default:will obey copy-on-write semantics:FutureWarning", + "default:.*will obey copy-on-write semantics:FutureWarning", ] python_files = [ "test_*.py" ] testpaths = [ From 22f2236477ff4b327951fa3db934815f5cc0ea7f Mon Sep 17 00:00:00 2001 From: Ilan Gold Date: Thu, 5 Feb 2026 15:15:19 +0100 Subject: [PATCH 15/23] Update src/anndata/_settings.py Co-authored-by: Philipp A. --- src/anndata/_settings.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/anndata/_settings.py b/src/anndata/_settings.py index 83515e54d..a5651ba28 100644 --- a/src/anndata/_settings.py +++ b/src/anndata/_settings.py @@ -529,7 +529,11 @@ def validate_sparse_settings(val: Any, settings: SettingsManager) -> None: settings.register( "copy_on_write_X", default_value=False, - description="Whether to copy-on-write X. Currently `my_adata_view[subset].X = value` will write back to the original AnnData object at the `subset` location. `X` is the only element where this behavior is implemented though.", + description=( + "Whether to copy-on-write X. " + "Currently `my_adata_view[subset].X = value` will write back to the original AnnData object at the `subset` location. " + "`X` is the only element where this behavior is implemented though." + ), validate=validate_bool, get_from_env=check_and_get_bool, ) From e06e831e17791df309a0577d65c45814f32bfbac Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Thu, 5 Feb 2026 15:24:12 +0100 Subject: [PATCH 16/23] fix: tests --- tests/test_x.py | 64 +++++++++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/tests/test_x.py b/tests/test_x.py index 1d61f70cd..4521b8eae 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -51,34 +51,42 @@ def test_repeat_indices_view(): @pytest.mark.parametrize("orig_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("new_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("copy_on_write_X", [True, False], ids=["CoW", "update"]) -def test_setter_view(orig_array_type, new_array_type, *, copy_on_write_X: bool): - with ad.settings.override(copy_on_write_X=copy_on_write_X): - adata = gen_adata((10, 10), X_type=orig_array_type) - orig_X = adata.X - expected_X = asarray(orig_X.copy()) - to_assign = new_array_type(np.ones((9, 9))) - if not copy_on_write_X: - expected_X[:9, :9] = asarray(to_assign) - if ( - not copy_on_write_X - and isinstance(orig_X, np.ndarray) - and sparse.issparse(to_assign) - ): - # https://github.com/scverse/anndata/issues/500 - pytest.xfail("Cannot set a dense array with a sparse array") - view = adata[:9, :9] - with pytest.warns( - ImplicitModificationWarning if copy_on_write_X else FutureWarning, - match=r"initializing view as actual" - if copy_on_write_X - else r"will obey copy-on-write semantics", - ): - view.X = to_assign - assert_equal(view.X, to_assign) - assert isinstance(view.X, type(to_assign) if copy_on_write_X else type(orig_X)) - assert_equal(adata.X, expected_X) - # If cow, then not a view and if not cow, it is a view - assert view.is_view != copy_on_write_X +def test_setter_view( + orig_array_type, + new_array_type, + *, + copy_on_write_X: bool, + request: pytest.FixtureRequest, +): + ad.settings.copy_on_write_X = copy_on_write_X + adata = gen_adata((10, 10), X_type=orig_array_type) + orig_X = adata.X + expected_X = asarray(orig_X.copy()) + to_assign = new_array_type(np.ones((9, 9))) + if not copy_on_write_X: + expected_X[:9, :9] = asarray(to_assign) + if ( + not copy_on_write_X + and isinstance(orig_X, np.ndarray) + and sparse.issparse(to_assign) + ): + # https://github.com/scverse/anndata/issues/500 + request.applymarker( + pytest.mark.xfail("Cannot set a dense array with a sparse array") + ) + view = adata[:9, :9] + with pytest.warns( + ImplicitModificationWarning if copy_on_write_X else FutureWarning, + match=r"initializing view as actual" + if copy_on_write_X + else r"will obey copy-on-write semantics", + ): + view.X = to_assign + assert_equal(view.X, to_assign) + assert isinstance(view.X, type(to_assign) if copy_on_write_X else type(orig_X)) + assert_equal(adata.X, expected_X) + # If cow, then not a view and if not cow, it is a view + assert view.is_view != copy_on_write_X ############################### From b24e8f4693323d27146a2a70c364cca6cba34686 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Thu, 5 Feb 2026 15:28:16 +0100 Subject: [PATCH 17/23] fix: complexity --- src/anndata/_core/anndata.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 7ff1d3a88..3daea0911 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -601,8 +601,21 @@ def X(self) -> _XDataType | None: # else: # return X + def _handle_view_X_cow(self, value: _XDataType | None): + if self._is_view: + if settings.copy_on_write_X: + msg = "Setting element `.X` of view, initializing view as actual." + warn(msg, ImplicitModificationWarning) + new = self._mutated_copy(X=value) + self._init_as_actual(new) + return None + msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release. " + "In other words, this subset of your original `AnnData` will be copied-in-place and initialized with the value passed into this setter. " + "Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." + warn(msg, FutureWarning) + @X.setter - def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 + def X(self, value: _XDataType | None): # noqa: PLR0912 value = ( coerce_array(value, name="X", allow_array_like=True) if value is not None @@ -617,17 +630,7 @@ def X(self, value: _XDataType | None): # noqa: PLR0912, PLR0915 if not can_set_direct_if_not_none: msg = f"Data matrix has wrong shape {value.shape}, need to be {self.shape}." raise ValueError(msg) - if self._is_view: - if settings.copy_on_write_X: - msg = "Setting element `.X` of view, initializing view as actual." - warn(msg, ImplicitModificationWarning) - new = self._mutated_copy(X=value) - self._init_as_actual(new) - return None - msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release. " - "In other words, this subset of your original `AnnData` will be copied-in-place and initialized with the value passed into this setter. " - "Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." - warn(msg, FutureWarning) + self._handle_view_X_cow(value) if value is None: if self.isbacked: msg = "Cannot currently remove data matrix from backed object." From 3d8b3883d8982ddf6eddf3013ce4cd8a8faf7469 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Thu, 5 Feb 2026 15:40:20 +0100 Subject: [PATCH 18/23] chore: clean up xfail condition --- tests/test_x.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_x.py b/tests/test_x.py index 4521b8eae..4418b183f 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -65,14 +65,12 @@ def test_setter_view( to_assign = new_array_type(np.ones((9, 9))) if not copy_on_write_X: expected_X[:9, :9] = asarray(to_assign) - if ( - not copy_on_write_X - and isinstance(orig_X, np.ndarray) - and sparse.issparse(to_assign) - ): # https://github.com/scverse/anndata/issues/500 request.applymarker( - pytest.mark.xfail("Cannot set a dense array with a sparse array") + pytest.mark.xfail( + condition=isinstance(orig_X, np.ndarray) and sparse.issparse(to_assign), + reason="Cannot set a dense array with a sparse array", + ) ) view = adata[:9, :9] with pytest.warns( From c4832e15426d579d91dcfad9e2a81e67f805cf22 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Thu, 5 Feb 2026 16:16:41 +0100 Subject: [PATCH 19/23] fix: return early --- src/anndata/_core/anndata.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 3daea0911..9b7b6fcaf 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -608,11 +608,12 @@ def _handle_view_X_cow(self, value: _XDataType | None): warn(msg, ImplicitModificationWarning) new = self._mutated_copy(X=value) self._init_as_actual(new) - return None + return True msg = "Setting element `.X` of view of `AnnData` object will obey copy-on-write semantics in the next minor release. " "In other words, this subset of your original `AnnData` will be copied-in-place and initialized with the value passed into this setter. " "Set `anndata.settings.copy_on_write_X = True` to begin opting in to this behavior." warn(msg, FutureWarning) + return False @X.setter def X(self, value: _XDataType | None): # noqa: PLR0912 @@ -630,7 +631,8 @@ def X(self, value: _XDataType | None): # noqa: PLR0912 if not can_set_direct_if_not_none: msg = f"Data matrix has wrong shape {value.shape}, need to be {self.shape}." raise ValueError(msg) - self._handle_view_X_cow(value) + if self._handle_view_X_cow(value): + return None if value is None: if self.isbacked: msg = "Cannot currently remove data matrix from backed object." From 9b3dc8bfa341e3a2e9b86721344922a978489dd3 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Thu, 5 Feb 2026 16:35:20 +0100 Subject: [PATCH 20/23] fix: remove useless `xfail` --- tests/test_x.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_x.py b/tests/test_x.py index 4418b183f..771b50f96 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -65,13 +65,6 @@ def test_setter_view( to_assign = new_array_type(np.ones((9, 9))) if not copy_on_write_X: expected_X[:9, :9] = asarray(to_assign) - # https://github.com/scverse/anndata/issues/500 - request.applymarker( - pytest.mark.xfail( - condition=isinstance(orig_X, np.ndarray) and sparse.issparse(to_assign), - reason="Cannot set a dense array with a sparse array", - ) - ) view = adata[:9, :9] with pytest.warns( ImplicitModificationWarning if copy_on_write_X else FutureWarning, From dded80892bce9fac1a4e62bc513b89558384ebf2 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Thu, 5 Feb 2026 16:39:16 +0100 Subject: [PATCH 21/23] fix: remove `fixture` --- tests/test_x.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_x.py b/tests/test_x.py index 771b50f96..15fbe48f9 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -51,13 +51,7 @@ def test_repeat_indices_view(): @pytest.mark.parametrize("orig_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("new_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("copy_on_write_X", [True, False], ids=["CoW", "update"]) -def test_setter_view( - orig_array_type, - new_array_type, - *, - copy_on_write_X: bool, - request: pytest.FixtureRequest, -): +def test_setter_view(orig_array_type, new_array_type, *, copy_on_write_X: bool, f): ad.settings.copy_on_write_X = copy_on_write_X adata = gen_adata((10, 10), X_type=orig_array_type) orig_X = adata.X From 01236c7654c817491c586c9c5c073406851e2eac Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Thu, 5 Feb 2026 16:59:33 +0100 Subject: [PATCH 22/23] fix: no `f` --- tests/test_x.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_x.py b/tests/test_x.py index 15fbe48f9..ca9159d83 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -51,7 +51,7 @@ def test_repeat_indices_view(): @pytest.mark.parametrize("orig_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("new_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("copy_on_write_X", [True, False], ids=["CoW", "update"]) -def test_setter_view(orig_array_type, new_array_type, *, copy_on_write_X: bool, f): +def test_setter_view(orig_array_type, new_array_type, *, copy_on_write_X: bool): ad.settings.copy_on_write_X = copy_on_write_X adata = gen_adata((10, 10), X_type=orig_array_type) orig_X = adata.X From 53757876e3109c6df26947ee5055ce5a8f077db2 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Fri, 6 Feb 2026 09:55:29 +0100 Subject: [PATCH 23/23] fix: catch warning --- tests/test_x.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_x.py b/tests/test_x.py index ca9159d83..f15876049 100644 --- a/tests/test_x.py +++ b/tests/test_x.py @@ -2,6 +2,8 @@ from __future__ import annotations +from contextlib import nullcontext + import numpy as np import pandas as pd import pytest @@ -60,11 +62,18 @@ def test_setter_view(orig_array_type, new_array_type, *, copy_on_write_X: bool): if not copy_on_write_X: expected_X[:9, :9] = asarray(to_assign) view = adata[:9, :9] - with pytest.warns( - ImplicitModificationWarning if copy_on_write_X else FutureWarning, - match=r"initializing view as actual" - if copy_on_write_X - else r"will obey copy-on-write semantics", + with ( + pytest.warns( + ImplicitModificationWarning if copy_on_write_X else FutureWarning, + match=r"initializing view as actual" + if copy_on_write_X + else r"will obey copy-on-write semantics", + ), + pytest.warns(UserWarning, match=r"Trying to set a dense array") + if sparse.issparse(to_assign) + and isinstance(orig_X, np.ndarray) + and not copy_on_write_X + else nullcontext(), ): view.X = to_assign assert_equal(view.X, to_assign)