From dd15a5ce7621c767b3268c00610e55b6a87151b5 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 12:08:27 -0700 Subject: [PATCH] feat(utils): expose STANDARD_SECTIONS as the canonical section order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``iter_outer`` is currently the only way to enumerate AnnData's standard section names, which forces consumers that only need the names (membership checks, layout introspection, ecosystem packages mirroring the layout) to drive the generator and pay for a full ``getattr`` per section — reconstructing aligned mappings and, for backed AnnData, reopening and closing the backing file. Expose the section order as a module-level ``tuple`` and have ``iter_outer`` iterate it: - ``STANDARD_SECTIONS: tuple[AnnDataElem, ...]`` becomes the single source of truth. - ``iter_outer`` now loops over ``STANDARD_SECTIONS``; the yield order and exception behaviour for existing callers is unchanged. - Name-only consumers read the constant directly: ``from anndata.utils import STANDARD_SECTIONS``. This is a pure refactor behaviourally — no caller semantics change — and a small addition to the public surface. Downstream consumers (rich HTML repr in PR #2236, ecosystem packages) can iterate the constant with per-section ``try/except`` to stay usable when a single section's attribute access raises. --- src/anndata/utils.py | 40 +++++++++++++++++++++++++++------------- tests/test_utils.py | 23 ++++++++++++++++++++++- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/anndata/utils.py b/src/anndata/utils.py index bee9882f7..6913d09d2 100644 --- a/src/anndata/utils.py +++ b/src/anndata/utils.py @@ -438,24 +438,38 @@ def module_get_attr_redirect( raise AttributeError(msg) +#: Canonical order of the standard ``AnnData`` section attributes as used +#: by :func:`iter_outer`, the text repr and the HTML repr. Exposed as a +#: public constant so consumers that only need the names (e.g. membership +#: checks, ordering decisions, introspecting layout without accessing the +#: data) can reference it directly without driving :func:`iter_outer`, +#: which reconstructs aligned mappings and reopens the backing file per +#: yield. +STANDARD_SECTIONS: tuple[AnnDataElem, ...] = ( + "X", + "obs", + "var", + "uns", + "obsm", + "varm", + "obsp", + "varp", + "layers", + "raw", +) + + def iter_outer( adata, ) -> Generator[ tuple[AnnDataElem, AxisStorable | _XDataType | Dataset2D | pd.DataFrame] ]: - """Iterate over key-value pairs of the parent "elems" like aw, obs, varp etc""" - for attr_name in [ - "X", - "obs", - "var", - "uns", - "obsm", - "varm", - "obsp", - "varp", - "layers", - "raw", - ]: + """Iterate over key-value pairs of the parent "elems" like ``X``, ``obs``, + ``varp`` etc. + + The section order is :data:`STANDARD_SECTIONS`. + """ + for attr_name in STANDARD_SECTIONS: was_closed = adata.isbacked and not adata.file.is_open yield (attr_name, getattr(adata, attr_name)) if was_closed: diff --git a/tests/test_utils.py b/tests/test_utils.py index a5fce76cf..e1e202af5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,13 +2,14 @@ from itertools import repeat +import numpy as np import pandas as pd import pytest from scipy import sparse import anndata as ad from anndata.tests.helpers import gen_typed_df -from anndata.utils import make_index_unique +from anndata.utils import STANDARD_SECTIONS, iter_outer, make_index_unique def test_make_index_unique() -> None: @@ -59,3 +60,23 @@ def test_adata_unique_indices() -> None: pd.testing.assert_index_equal(v.obsm["df"].index, v.obs_names) pd.testing.assert_index_equal(v.varm["df"].index, v.var_names) + + +def test_standard_sections_is_iter_outer_order() -> None: + """``STANDARD_SECTIONS`` must match the section order ``iter_outer`` yields. + + Consumers that need only names (membership tests, layout introspection) + rely on this equivalence to avoid the extra cost of actually driving the + generator. + """ + adata = ad.AnnData(np.zeros((3, 4))) + assert tuple(name for name, _ in iter_outer(adata)) == STANDARD_SECTIONS + + +def test_standard_sections_contents() -> None: + """Every name in ``STANDARD_SECTIONS`` is accessible on a plain AnnData.""" + adata = ad.AnnData(np.zeros((3, 4))) + for name in STANDARD_SECTIONS: + assert hasattr(adata, name), ( + f"STANDARD_SECTIONS contains {name!r} but AnnData has no such attribute" + )