From 16092556cc1d7802620fab9f32a22614e8c7464b Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Tue, 14 Apr 2026 13:40:52 +0200 Subject: [PATCH 1/5] chore: use `scverse-misc` --- pyproject.toml | 1 + src/anndata/_core/aligned_mapping.py | 4 +- src/anndata/_core/anndata.py | 78 +++++++++------ src/anndata/_core/extensions.py | 138 +-------------------------- src/anndata/_io/read.py | 10 +- src/anndata/_io/write.py | 10 +- src/anndata/utils.py | 33 ------- 7 files changed, 72 insertions(+), 202 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 173c2ccaf..2b628e442 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "legacy-api-wrap", "zarr >=3.1", "typing-extensions; python_version<'3.13'", + "scverse-misc>=0.0.3", ] dynamic = [ "version" ] diff --git a/src/anndata/_core/aligned_mapping.py b/src/anndata/_core/aligned_mapping.py index 3ac1c33d7..b7d2913e1 100644 --- a/src/anndata/_core/aligned_mapping.py +++ b/src/anndata/_core/aligned_mapping.py @@ -8,13 +8,13 @@ import numpy as np import pandas as pd +from scverse_misc import Deprecation, deprecated from .._warnings import ExperimentalFeatureWarning, ImplicitModificationWarning from ..compat import AwkArray, CSArray, CSMatrix, CupyArray, XDataset from ..utils import ( axis_len, convert_to_dict, - deprecated, deprecation_msg, raise_value_error_if_multiindex_columns, warn, @@ -126,7 +126,7 @@ def _view(self, parent: AnnData, subset_idx: I) -> AlignedView[Self, I]: """Returns a subset copy-on-write view of the object.""" return self._view_class(self, parent, subset_idx) - @deprecated(deprecation_msg("as_dict", "dict(obj)")) + @deprecated(Deprecation("0.10.2", deprecation_msg("as_dict", "dict(obj)"))) def as_dict(self) -> dict: return dict(self) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 34a16226e..7df4fed68 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -19,6 +19,7 @@ from numpy import ma from pandas.api.types import infer_dtype from scipy.sparse import issparse +from scverse_misc import Deprecation, deprecated from anndata._warnings import ImplicitModificationWarning @@ -36,7 +37,6 @@ from ..logging import anndata_logger as logger from ..utils import ( axis_len, - deprecated, deprecation_msg, ensure_df_homogeneous, raise_value_error_if_multiindex_columns, @@ -69,7 +69,7 @@ @set_module("anndata") -class AnnData(metaclass=utils.DeprecationMixinMeta): # noqa: PLW1641 +class AnnData: # noqa: PLW1641 """\ An annotated data matrix. @@ -906,9 +906,12 @@ def uns(self): """ @deprecated( - deprecation_msg( - *("obs_keys", "obs"), - "(e.g. `k in adata.obs` or `str(adata.obs.columns.tolist())`)", + Deprecation( + "0.12.3", + deprecation_msg( + *("obs_keys", "obs"), + "(e.g. `k in adata.obs` or `str(adata.obs.columns.tolist())`)", + ), ) ) def obs_keys(self) -> list[str]: @@ -916,9 +919,12 @@ def obs_keys(self) -> list[str]: return self._obs.keys().tolist() @deprecated( - deprecation_msg( - *("var_keys", "var"), - "(e.g. `k in adata.var` or `str(adata.var.columns.tolist())`)", + Deprecation( + "0.12.3", + deprecation_msg( + *("var_keys", "var"), + "(e.g. `k in adata.var` or `str(adata.var.columns.tolist())`)", + ), ) ) def var_keys(self) -> list[str]: @@ -926,9 +932,12 @@ def var_keys(self) -> list[str]: return self._var.keys().tolist() @deprecated( - deprecation_msg( - *("obsm_keys", "obsm"), - "(e.g. `k in adata.obsm` or `adata.obsm.keys() | {'u'}`)", + Deprecation( + "0.12.3", + deprecation_msg( + *("obsm_keys", "obsm"), + "(e.g. `k in adata.obsm` or `adata.obsm.keys() | {'u'}`)", + ), ) ) def obsm_keys(self) -> list[str]: @@ -936,9 +945,12 @@ def obsm_keys(self) -> list[str]: return list(self.obsm.keys()) @deprecated( - deprecation_msg( - *("varm_keys", "varm"), - "(e.g. `k in adata.varm` or `adata.varm.keys() | {'u'}`)", + Deprecation( + "0.12.3", + deprecation_msg( + *("varm_keys", "varm"), + "(e.g. `k in adata.varm` or `adata.varm.keys() | {'u'}`)", + ), ) ) def varm_keys(self) -> list[str]: @@ -946,8 +958,11 @@ def varm_keys(self) -> list[str]: return list(self.varm.keys()) @deprecated( - deprecation_msg( - "uns_keys", "uns", "(e.g. `k in adata.uns` or `sorted(adata.uns)`)" + Deprecation( + "0.13", + deprecation_msg( + "uns_keys", "uns", "(e.g. `k in adata.uns` or `sorted(adata.uns)`)" + ), ) ) def uns_keys(self) -> list[str]: @@ -1257,10 +1272,13 @@ def to_df(self, layer: str | None = None) -> pd.DataFrame: return pd.DataFrame(X, index=self.obs_names, columns=self.var_names) @deprecated( - deprecation_msg( - "obs_vector", - "anndata.acc.A", - "E.g. `vec = adata[A.obs['foo']]` or `vec = adata[A.layers['l']['bar', :]]`", + Deprecation( + "0.13", + deprecation_msg( + "obs_vector", + "anndata.acc.A", + "E.g. `vec = adata[A.obs['foo']]` or `vec = adata[A.layers['l']['bar', :]]`", + ), ) ) def obs_vector(self, k: str, /, *, layer: str | None = None) -> np.ndarray: @@ -1286,10 +1304,13 @@ def obs_vector(self, k: str, /, *, layer: str | None = None) -> np.ndarray: return _get_vector_ambiguous(self, k, "obs", layer=layer) @deprecated( - deprecation_msg( - "var_vector", - "anndata.acc.A", - "E.g. `vec = adata[A.var['foo']]` or `vec = adata[A.layers['l'][:, 'bar']]`", + Deprecation( + "0.13", + deprecation_msg( + "var_vector", + "anndata.acc.A", + "E.g. `vec = adata[A.var['foo']]` or `vec = adata[A.layers['l'][:, 'bar']]`", + ), ) ) def var_vector(self, k: str, /, *, layer: str | None = None) -> np.ndarray: @@ -1603,8 +1624,11 @@ def write_csvs( write_csvs(dirname, self, skip_data=skip_data, sep=sep) @deprecated( - "Deprecated in favor of other formats, e.g. `write_h5ad`. " - "Loom isn’t well-maintained and supports only a subset of anndata features." + Deprecation( + "0.13", + "Deprecated in favor of other formats, e.g. `write_h5ad`. " + "Loom isn’t well-maintained and supports only a subset of anndata features.", + ) ) @old_positionals("write_obsm_varm") def write_loom( @@ -1742,7 +1766,7 @@ def _has_X(self) -> bool: # -------------------------------------------------------------------------- @property - @deprecated(deprecation_msg("isview", "is_view")) + @deprecated(Deprecation("0.7.2", deprecation_msg("isview", "is_view"))) def isview(self) -> bool: return self.is_view diff --git a/src/anndata/_core/extensions.py b/src/anndata/_core/extensions.py index 180a15a61..a8590d90d 100644 --- a/src/anndata/_core/extensions.py +++ b/src/anndata/_core/extensions.py @@ -1,148 +1,18 @@ from __future__ import annotations -import inspect -from typing import TYPE_CHECKING, get_type_hints, overload +from typing import TYPE_CHECKING + +from scverse_misc import make_register_namespace_decorator from ..types import ExtensionNamespace -from ..utils import warn from .anndata import AnnData if TYPE_CHECKING: from collections.abc import Callable - -# Based off of the extension framework in Polars -# https://github.com/pola-rs/polars/blob/main/py-polars/polars/api.py - __all__ = ["register_anndata_namespace"] -# Reserved namespaces include accessors built into AnnData (currently there are none) -# and all current attributes of AnnData -_reserved_namespaces: set[str] = set(dir(AnnData)) - - -class AccessorNameSpace[NameSpT: ExtensionNamespace](ExtensionNamespace): - """Establish property-like namespace object for user-defined functionality.""" - - def __init__(self, name: str, namespace: type[NameSpT]) -> None: - self._accessor = name - self._ns = namespace - - @overload - def __get__[T](self, instance: None, cls: type[T]) -> type[NameSpT]: ... - - @overload - def __get__[T](self, instance: T, cls: type[T]) -> NameSpT: ... - - def __get__[T](self, instance: T | None, cls: type[T]) -> NameSpT | type[NameSpT]: - if instance is None: - return self._ns - - ns_instance = self._ns(instance) # type: ignore[call-arg] - setattr(instance, self._accessor, ns_instance) - return ns_instance - - -def _check_namespace_signature(ns_class: type) -> None: - """Validate the signature of a namespace class for AnnData extensions. - - This function ensures that any class intended to be used as an extension namespace - has a properly formatted `__init__` method such that: - - 1. Accepts at least two parameters (self and adata) - 2. Has 'adata' as the name of the second parameter - 3. Has the second parameter properly type-annotated as 'AnnData' or any equivalent import alias - - The function performs runtime validation of these requirements before a namespace - can be registered through the `register_anndata_namespace` decorator. - - Parameters - ---------- - ns_class - The namespace class to validate. - - Raises - ------ - TypeError - If the `__init__` method has fewer than 2 parameters (missing the AnnData parameter). - AttributeError - If the second parameter of `__init__` lacks a type annotation. - TypeError - If the second parameter of `__init__` is not named 'adata'. - TypeError - If the second parameter of `__init__` is not annotated as the 'AnnData' class. - TypeError - If both the name and type annotation of the second parameter are incorrect. - - """ - sig = inspect.signature(ns_class.__init__) - params = list(sig.parameters.values()) - - # Ensure there are at least two parameters (self and adata) - if len(params) < 2: - error_msg = "Namespace initializer must accept an AnnData instance as the second parameter." - raise TypeError(error_msg) - - # Get the second parameter (expected to be 'adata') - param = params[1] - if param.annotation is inspect._empty: - err_msg = "Namespace initializer's second parameter must be annotated as the 'AnnData' class, got empty annotation." - raise AttributeError(err_msg) - - name_ok = param.name == "adata" - - # Resolve the annotation using get_type_hints to handle forward references and aliases. - try: - type_hints = get_type_hints(ns_class.__init__) - resolved_type = type_hints.get(param.name, param.annotation) - except NameError as e: - err_msg = f"Namespace initializer's second parameter must be named 'adata', got '{param.name}'." - raise NameError(err_msg) from e - - type_ok = resolved_type is AnnData - - match (name_ok, type_ok): - case (True, True): - return # Signature is correct. - case (False, True): - msg = f"Namespace initializer's second parameter must be named 'adata', got {param.name!r}." - raise TypeError(msg) - case (True, False): - type_repr = getattr(resolved_type, "__name__", str(resolved_type)) - msg = f"Namespace initializer's second parameter must be annotated as the 'AnnData' class, got '{type_repr}'." - raise TypeError(msg) - case _: - type_repr = getattr(resolved_type, "__name__", str(resolved_type)) - msg = ( - f"Namespace initializer's second parameter must be named 'adata', got {param.name!r}. " - f"And must be annotated as 'AnnData', got {type_repr!r}." - ) - raise TypeError(msg) - - -def _create_namespace[NameSpT: ExtensionNamespace]( - name: str, cls: type[AnnData] -) -> Callable[[type[NameSpT]], type[NameSpT]]: - """Register custom namespace against the underlying AnnData class.""" - - def namespace(ns_class: type[NameSpT]) -> type[NameSpT]: - _check_namespace_signature(ns_class) # Perform the runtime signature check - if name in _reserved_namespaces: - msg = f"cannot override reserved attribute {name!r}" - raise AttributeError(msg) - elif name in cls._accessors: - warn( - f"Overriding existing custom namespace {name!r} (on {cls.__name__!r})", - UserWarning, - ) - setattr(cls, name, AccessorNameSpace(name, ns_class)) - cls._accessors.add(name) - return ns_class - - return namespace - - def register_anndata_namespace[NameSpT: ExtensionNamespace]( name: str, ) -> Callable[[type[NameSpT]], type[NameSpT]]: @@ -234,4 +104,4 @@ def register_anndata_namespace[NameSpT: ExtensionNamespace]( AnnData object with n_obs × n_vars = 100 × 2000 layers: 'log1p', 'arcsinh' """ - return _create_namespace(name, AnnData) + return make_register_namespace_decorator(AnnData, "adata", name, "numpy") diff --git a/src/anndata/_io/read.py b/src/anndata/_io/read.py index 2211864ed..8a2cbd5e5 100644 --- a/src/anndata/_io/read.py +++ b/src/anndata/_io/read.py @@ -12,10 +12,11 @@ import numpy as np import pandas as pd from scipy import sparse +from scverse_misc import Deprecation, deprecated from .. import AnnData from ..compat import old_positionals, pandas_as_str -from ..utils import deprecated, warn +from ..utils import warn from .utils import is_float if TYPE_CHECKING: @@ -157,8 +158,11 @@ def _fmt_loom_axis_attrs( @deprecated( - "Deprecated in favor of other formats, e.g. (`write_h5ad` and then) `read_h5ad`. " - "Loom isn’t well-maintained and supports only a subset of anndata features.", + Deprecation( + "0.13", + "Deprecated in favor of other formats, e.g. (`write_h5ad` and then) `read_h5ad`. " + "Loom isn’t well-maintained and supports only a subset of anndata features.", + ) ) @old_positionals( "sparse", diff --git a/src/anndata/_io/write.py b/src/anndata/_io/write.py index 3691f929a..f765f85f7 100644 --- a/src/anndata/_io/write.py +++ b/src/anndata/_io/write.py @@ -8,13 +8,14 @@ import numpy as np import pandas as pd from scipy.sparse import issparse +from scverse_misc import Deprecation, deprecated from anndata._io.utils import no_write_dataset_2d from .._warnings import WriteWarning from ..compat import old_positionals from ..logging import get_logger -from ..utils import deprecated, warn +from ..utils import warn if TYPE_CHECKING: from os import PathLike @@ -84,8 +85,11 @@ def write_csvs( @deprecated( - "Deprecated in favor of other formats, e.g. `write_h5ad`. " - "Loom isn’t well-maintained and supports only a subset of anndata features." + Deprecation( + "0.13", + "Deprecated in favor of other formats, e.g. `write_h5ad`. " + "Loom isn’t well-maintained and supports only a subset of anndata features.", + ) ) @no_write_dataset_2d @old_positionals("write_obsm_varm") diff --git a/src/anndata/utils.py b/src/anndata/utils.py index 9089f90a5..31b6f1bee 100644 --- a/src/anndata/utils.py +++ b/src/anndata/utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import re -import sys import warnings from functools import partial, singledispatch from types import FunctionType, UnionType @@ -365,16 +364,6 @@ def warn_once(msg: str, category: type[Warning]) -> None: warnings.filterwarnings("ignore", category=category, message=re.escape(msg)) -if TYPE_CHECKING: - from warnings import deprecated -else: - if sys.version_info >= (3, 13): - from warnings import deprecated as _deprecated - else: - from typing_extensions import deprecated as _deprecated - deprecated = partial(_deprecated, category=FutureWarning) - - def deprecation_msg( name: LiteralString, new_name: LiteralString, add_msg: LiteralString | None = None ) -> LiteralString: @@ -387,28 +376,6 @@ def deprecation_msg( return msg -class DeprecationMixinMeta(type): - """\ - Use this as superclass so deprecated methods and properties - do not appear in vars(MyClass)/dir(MyClass) - """ - - def __dir__(cls): - dont_hide = getattr(cls, "_DONT_HIDE_DEPRECATED", set()) - - def is_hidden(attr: object) -> bool: - if isinstance(attr, property): - attr = attr.fget - is_deprecated = bool(getattr(attr, "__deprecated__", None)) - return is_deprecated and getattr(attr, "__name__", None) not in dont_hide - - return [ - item - for item in type.__dir__(cls) - if not is_hidden(getattr(cls, item, None)) - ] - - def set_module[C: FunctionType | type](name: str, /) -> Callable[[C], C]: def decorator(f: C) -> C: f.__module__ = name From 6f19ce77ee632bc0d8f4e219a8eeb51dbc63639e Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Tue, 14 Apr 2026 14:07:01 +0200 Subject: [PATCH 2/5] fix: doc strings --- src/anndata/_core/anndata.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 7df4fed68..95dd75c64 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -1283,8 +1283,7 @@ def to_df(self, layer: str | None = None) -> pd.DataFrame: ) def obs_vector(self, k: str, /, *, layer: str | None = None) -> np.ndarray: """\ - Convenience function for returning a 1 dimensional ndarray of values - from :attr:`X`, :attr:`layers`\\ `[k]`, or :attr:`obs`. + Convenience function for returning a 1 dimensional ndarray of values from :attr:`X`, :attr:`layers`\\ `[k]`, or :attr:`obs`. Made for convenience, not performance. Intentionally permissive about arguments, for easy iterative use. @@ -1315,8 +1314,7 @@ def obs_vector(self, k: str, /, *, layer: str | None = None) -> np.ndarray: ) def var_vector(self, k: str, /, *, layer: str | None = None) -> np.ndarray: """\ - Convenience function for returning a 1 dimensional ndarray of values - from :attr:`X`, :attr:`layers`\\ `[k]`, or :attr:`obs`. + Convenience function for returning a 1 dimensional ndarray of values from :attr:`X`, :attr:`layers`\\ `[k]`, or :attr:`obs`. Made for convenience, not performance. Intentionally permissive about arguments, for easy iterative use. @@ -1768,6 +1766,7 @@ def _has_X(self) -> bool: @property @deprecated(Deprecation("0.7.2", deprecation_msg("isview", "is_view"))) def isview(self) -> bool: + """Whether or not this object is a view.""" return self.is_view def _clean_up_old_format(self, uns): From 8c6c54f7d09af47759504c8e819d659b6428eabf Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Tue, 14 Apr 2026 14:49:57 +0200 Subject: [PATCH 3/5] fix: tests --- src/anndata/_core/extensions.py | 101 +------------------------------- src/anndata/types.py | 20 +------ tests/test_base.py | 2 +- tests/test_extensions.py | 53 ++--------------- tests/test_get_vector.py | 2 +- 5 files changed, 12 insertions(+), 166 deletions(-) diff --git a/src/anndata/_core/extensions.py b/src/anndata/_core/extensions.py index a8590d90d..9835c0e9d 100644 --- a/src/anndata/_core/extensions.py +++ b/src/anndata/_core/extensions.py @@ -1,107 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from scverse_misc import make_register_namespace_decorator -from ..types import ExtensionNamespace from .anndata import AnnData -if TYPE_CHECKING: - from collections.abc import Callable - __all__ = ["register_anndata_namespace"] -def register_anndata_namespace[NameSpT: ExtensionNamespace]( - name: str, -) -> Callable[[type[NameSpT]], type[NameSpT]]: - """Decorator for registering custom functionality with an :class:`~anndata.AnnData` object. - - This decorator allows you to extend AnnData objects with custom methods and properties - organized under a namespace. The namespace becomes accessible as an attribute on AnnData - instances, providing a clean way to you to add domain-specific functionality without modifying - the AnnData class itself, or extending the class with additional methods as you see fit in your workflow. - - Parameters - ---------- - name - Name under which the accessor should be registered. This will be the attribute name - used to access your namespace's functionality on AnnData objects (e.g., `adata.{name}`). - Cannot conflict with existing AnnData attributes like `obs`, `var`, `X`, etc. The list of reserved - attributes includes everything outputted by `dir(AnnData)`. - - Returns - ------- - A decorator that registers the decorated class as a custom namespace. - - Notes - ----- - Implementation requirements: - - 1. The decorated class must have an `__init__` method that accepts exactly one parameter - (besides `self`) named `adata` and annotated with type :class:`~anndata.AnnData`. - 2. The namespace will be initialized with the AnnData object on first access and then - cached on the instance. - 3. If the namespace name conflicts with an existing namespace, a warning is issued. - 4. If the namespace name conflicts with a built-in AnnData attribute, an AttributeError is raised. - - Examples - -------- - Simple transformation namespace with two methods: - - >>> import anndata as ad - >>> import numpy as np - >>> - >>> @ad.register_anndata_namespace("transform") - ... class TransformX: - ... def __init__(self, adata: ad.AnnData): - ... self._adata = adata - ... - ... def log1p( - ... self, layer: str = None, inplace: bool = False - ... ) -> ad.AnnData | None: - ... '''Log1p transform the data.''' - ... data = self._adata.layers[layer] if layer else self._adata.X - ... log1p_data = np.log1p(data) - ... - ... if layer: - ... layer_name = f"{layer}_log1p" if not inplace else layer - ... else: - ... layer_name = "log1p" - ... - ... self._adata.layers[layer_name] = log1p_data - ... - ... if not inplace: - ... return self._adata - ... - ... def arcsinh( - ... self, layer: str = None, scale: float = 1.0, inplace: bool = False - ... ) -> ad.AnnData | None: - ... '''Arcsinh transform the data with optional scaling.''' - ... data = self._adata.layers[layer] if layer else self._adata.X - ... asinh_data = np.arcsinh(data / scale) - ... - ... if layer: - ... layer_name = f"{layer}_arcsinh" if not inplace else layer - ... else: - ... layer_name = "arcsinh" - ... - ... self._adata.layers[layer_name] = asinh_data - ... - ... if not inplace: - ... return self._adata - >>> - >>> # Create an AnnData object - >>> rng = np.random.default_rng(42) - >>> adata = ad.AnnData(X=rng.poisson(1, size=(100, 2000))) - >>> - >>> # Use the registered namespace - >>> adata.transform.log1p() # Transforms X and returns the AnnData object - AnnData object with n_obs × n_vars = 100 × 2000 - layers: 'log1p' - >>> adata.transform.arcsinh() # Transforms X and returns the AnnData object - AnnData object with n_obs × n_vars = 100 × 2000 - layers: 'log1p', 'arcsinh' - """ - return make_register_namespace_decorator(AnnData, "adata", name, "numpy") +register_anndata_namespace = make_register_namespace_decorator( + AnnData, "adata", "register_anndata_namespace", "numpy" +) diff --git a/src/anndata/types.py b/src/anndata/types.py index aa23d10f2..bf3765716 100644 --- a/src/anndata/types.py +++ b/src/anndata/types.py @@ -2,30 +2,14 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable +from scverse_misc import ExtensionNamespace # noqa: F401 + if TYPE_CHECKING: from enum import Enum from typing import Any, Literal from array_api.latest import ArrayNamespace - from ._core.anndata import AnnData - - -@runtime_checkable -class ExtensionNamespace(Protocol): - """Protocol for extension namespaces. - - Enforces that the namespace initializer accepts a class with the proper `__init__` method. - Protocol's can't enforce that the `__init__` accepts the correct types. See - `_check_namespace_signature` for that. This is mainly useful for static type - checking with mypy and IDEs. - """ - - def __init__(self, adata: AnnData) -> None: - """ - Used to enforce the correct signature for extension namespaces. - """ - @runtime_checkable class SupportsArrayApi(Protocol): diff --git a/tests/test_base.py b/tests/test_base.py index 254e483b8..363bf5aba 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -634,7 +634,7 @@ def test_to_df_dense(): pd.testing.assert_index_equal(X_df.index, layer_df.index) -@pytest.mark.filterwarnings("ignore:Use anndata.acc.A instead of:FutureWarning") +@pytest.mark.filterwarnings("ignore:.*Use anndata.acc.A instead of.*:FutureWarning") def test_convenience(subtests: pytest.Subtests) -> None: adata = adata_sparse.copy() adata.layers["x2"] = adata.X * 2 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2724ba5b2..717af07bb 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -6,7 +6,6 @@ import pytest import anndata as ad -from anndata._core import extensions if TYPE_CHECKING: from collections.abc import Generator @@ -46,46 +45,6 @@ def adata() -> ad.AnnData: return ad.AnnData(X=rng.poisson(1, size=(10, 10))) -def test_accessor_namespace() -> None: - """Test the behavior of the AccessorNameSpace descriptor. - - This test verifies that: - - When accessed at the class level (i.e., without an instance), the descriptor - returns the namespace type. - - When accessed via an instance, the descriptor instantiates the namespace, - passing the instance to its constructor. - - The instantiated namespace is then cached on the instance such that subsequent - accesses of the same attribute return the cached namespace instance. - """ - - # Define a dummy namespace class to be used via the descriptor. - class DummyNamespace: - def __init__(self, adata: ad.AnnData) -> None: - self._adata = adata - - def foo(self) -> str: - return "foo" - - class Dummy: - pass - - descriptor = extensions.AccessorNameSpace("dummy", DummyNamespace) - - # When accessed on the class, it should return the namespace type. - ns_class = descriptor.__get__(None, Dummy) - assert ns_class is DummyNamespace - - # When accessed via an instance, it should instantiate DummyNamespace. - dummy_obj = Dummy() - ns_instance = descriptor.__get__(dummy_obj, Dummy) - assert isinstance(ns_instance, DummyNamespace) - assert ns_instance._adata is dummy_obj - - # __get__ should cache the namespace instance on the object. - # Subsequent access should return the same cached instance. - assert dummy_obj.dummy is ns_instance - - def test_descriptor_instance_caching(dummy_namespace: type, adata: ad.AnnData) -> None: """Test that namespace instances are cached on individual AnnData objects.""" # First access creates the instance @@ -101,8 +60,6 @@ def test_register_namespace_basic(dummy_namespace: type, adata: ad.AnnData) -> N def test_register_namespace_override(dummy_namespace: type) -> None: """Test namespace registration and override behavior.""" - assert "dummy" in ad.AnnData._accessors - # Override should warn and update the namespace with pytest.warns( UserWarning, match="Overriding existing custom namespace 'dummy'" @@ -156,7 +113,7 @@ def test_missing_param() -> None: """Test that a namespace missing the second parameter is rejected.""" with pytest.raises( TypeError, - match=r"Namespace initializer must accept an AnnData instance as the second parameter\.", + match=r"Namespace initializer must accept a AnnData instance as the second parameter.", ): @ad.register_anndata_namespace("missing_param") @@ -169,7 +126,7 @@ def test_wrong_name() -> None: """Test that a namespace with wrong parameter name is rejected.""" with pytest.raises( TypeError, - match=r"Namespace initializer's second parameter must be named 'adata', got 'notadata'\.", + match=r"Namespace initializer's second parameter must be named 'adata', got 'notadata'.", ): @ad.register_anndata_namespace("wrong_name") @@ -182,7 +139,7 @@ def test_wrong_annotation() -> None: """Test that a namespace with wrong parameter annotation is rejected.""" with pytest.raises( TypeError, - match=r"Namespace initializer's second parameter must be annotated as the 'AnnData' class, got 'int'\.", + match=r"Namespace initializer's second parameter must be annotated as the 'AnnData' class, got 'int'.", ): @ad.register_anndata_namespace("wrong_annotation") @@ -206,8 +163,8 @@ def test_both_wrong() -> None: with pytest.raises( TypeError, match=( - r"Namespace initializer's second parameter must be named 'adata', got 'info'\. " - r"And must be annotated as 'AnnData', got 'str'\." + r"Namespace initializer's second parameter must be named 'adata', got 'info'. " + r"And must be annotated as 'AnnData', got 'str'." ), ): diff --git a/tests/test_get_vector.py b/tests/test_get_vector.py index 9c6def276..882ca7c89 100644 --- a/tests/test_get_vector.py +++ b/tests/test_get_vector.py @@ -8,7 +8,7 @@ import anndata as ad pytestmark = [ - pytest.mark.filterwarnings("ignore:Use anndata.acc.A instead of:FutureWarning"), + pytest.mark.filterwarnings("ignore:.*Use anndata.acc.A instead of.*:FutureWarning"), ] OBS_KEYS = [ From c989473794eeec00463e11abcd88060a6dc9738a Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Tue, 14 Apr 2026 15:43:06 +0200 Subject: [PATCH 4/5] fix: warn on `ExtensionNamespace` --- src/anndata/types.py | 22 ++++++++++++++++++++-- tests/test_deprecations.py | 7 +++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/anndata/types.py b/src/anndata/types.py index bf3765716..aa2bc795b 100644 --- a/src/anndata/types.py +++ b/src/anndata/types.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable -from scverse_misc import ExtensionNamespace # noqa: F401 - if TYPE_CHECKING: from enum import Enum from typing import Any, Literal @@ -32,3 +30,23 @@ def __dlpack__( copy: bool | None = None, ) -> Any: ... def __dlpack_device__(self) -> tuple[int, int]: ... + + +def __getattr__(key: str): + match key: + case "ExtensionNamespace": + from scverse_misc import ExtensionNamespace + + from .utils import warn + + msg = ( + "Importing ExtensionNamespace from `types` is deprecated. " + "Please use scverse_misc instead." + ) + warn(msg, FutureWarning) + return ExtensionNamespace + case "SupportsArrayApi": + return SupportsArrayApi + case _: + msg = f"types has no attribute {key!r}" + raise AttributeError(msg) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index cd5308a93..7b26920ca 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -101,6 +101,13 @@ def test_warn_on_deprecated__io_module(): from anndata._io import read_h5ad # noqa +def test_warn_on_deprecated_extension_namespace(): + with pytest.warns( + FutureWarning, match=r"Importing ExtensionNamespace from `types`" + ): + from anndata.types import ExtensionNamespace # noqa + + @pytest.mark.parametrize("name", ["obs", "var", "obsm", "varm", "uns"]) def test_keys_function_warns(adata: AnnData, name) -> None: with pytest.warns(FutureWarning, match=rf"{name}_keys is deprecated"): From 3f36b4e7e2694e31d2306e11255fcd2a1573fc38 Mon Sep 17 00:00:00 2001 From: ilan-gold Date: Tue, 14 Apr 2026 15:43:48 +0200 Subject: [PATCH 5/5] fix: bring back periods --- tests/test_extensions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 717af07bb..05dd33046 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -113,7 +113,7 @@ def test_missing_param() -> None: """Test that a namespace missing the second parameter is rejected.""" with pytest.raises( TypeError, - match=r"Namespace initializer must accept a AnnData instance as the second parameter.", + match=r"Namespace initializer must accept a AnnData instance as the second parameter\.", ): @ad.register_anndata_namespace("missing_param") @@ -126,7 +126,7 @@ def test_wrong_name() -> None: """Test that a namespace with wrong parameter name is rejected.""" with pytest.raises( TypeError, - match=r"Namespace initializer's second parameter must be named 'adata', got 'notadata'.", + match=r"Namespace initializer's second parameter must be named 'adata', got 'notadata'\.", ): @ad.register_anndata_namespace("wrong_name") @@ -139,7 +139,7 @@ def test_wrong_annotation() -> None: """Test that a namespace with wrong parameter annotation is rejected.""" with pytest.raises( TypeError, - match=r"Namespace initializer's second parameter must be annotated as the 'AnnData' class, got 'int'.", + match=r"Namespace initializer's second parameter must be annotated as the 'AnnData' class, got 'int'\.", ): @ad.register_anndata_namespace("wrong_annotation") @@ -163,8 +163,8 @@ def test_both_wrong() -> None: with pytest.raises( TypeError, match=( - r"Namespace initializer's second parameter must be named 'adata', got 'info'. " - r"And must be annotated as 'AnnData', got 'str'." + r"Namespace initializer's second parameter must be named 'adata', got 'info'\. " + r"And must be annotated as 'AnnData', got 'str'\." ), ):