pyadata-cli 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adata/core/info.py ADDED
@@ -0,0 +1,276 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Tuple, Dict, Any, Union
4
+
5
+ import numpy as np
6
+
7
+ from adata.elements import spec
8
+ from adata.elements.read import dataframe_columns, element_len, resolve_index
9
+ from adata.storage import is_dataset, is_group, is_hdf5_dataset
10
+
11
+
12
+ def _decode_attr(value: Any) -> Any:
13
+ if isinstance(value, bytes):
14
+ return value.decode("utf-8")
15
+ return value
16
+
17
+
18
+ def _base_result() -> Dict[str, Any]:
19
+ return {
20
+ "type": "unknown",
21
+ "export_as": None,
22
+ "encoding": None,
23
+ "shape": None,
24
+ "dtype": None,
25
+ "details": "",
26
+ "version": None,
27
+ "ordered": None,
28
+ }
29
+
30
+
31
+ #: Declared encoding-type -> (reported type, suggested export format).
32
+ _ENCODING_TYPES = {
33
+ spec.ANNDATA: ("anndata", None),
34
+ spec.RAW: ("raw", None),
35
+ spec.DICT: ("dict", "json"),
36
+ spec.DATAFRAME: ("dataframe", "csv"),
37
+ spec.ARRAY: ("array", "npy"),
38
+ spec.STRING_ARRAY: ("string-array", "csv"),
39
+ spec.CATEGORICAL: ("categorical", "csv"),
40
+ spec.CSR_MATRIX: ("sparse-matrix", "mtx"),
41
+ spec.CSC_MATRIX: ("sparse-matrix", "mtx"),
42
+ spec.NULLABLE_INTEGER: ("nullable-array", "csv"),
43
+ spec.NULLABLE_BOOLEAN: ("nullable-array", "csv"),
44
+ spec.NULLABLE_STRING_ARRAY: ("nullable-array", "csv"),
45
+ spec.NUMERIC_SCALAR: ("scalar", "json"),
46
+ spec.STRING: ("scalar", "json"),
47
+ spec.NULL: ("null", "json"),
48
+ spec.AWKWARD_ARRAY: ("awkward-array", "json"),
49
+ }
50
+
51
+
52
+ def get_entry_type(entry: Any) -> Dict[str, Any]:
53
+ """Describe an element: its type, shape, and how it can be exported.
54
+
55
+ The declared `encoding-type` is authoritative and is consulted first;
56
+ structural inspection is only a fallback, for files written by anndata
57
+ 0.7.x and earlier which carry no encoding attributes. Inferring first
58
+ would let a group merely *containing* a member called `obs_names` be
59
+ misreported as a dataframe.
60
+ """
61
+ result = _base_result()
62
+
63
+ enc, enc_ver = spec.encoding_of(entry)
64
+ result["encoding"] = enc
65
+ result["version"] = enc_ver
66
+
67
+ if is_dataset(entry):
68
+ result["shape"] = entry.shape
69
+ result["dtype"] = str(entry.dtype)
70
+
71
+ if enc in _ENCODING_TYPES:
72
+ result["type"], result["export_as"] = _ENCODING_TYPES[enc]
73
+ _describe(entry, result, enc)
74
+ return result
75
+
76
+ return _infer_untagged(entry, result)
77
+
78
+
79
+ def _describe(entry: Any, result: Dict[str, Any], enc: str) -> None:
80
+ """Fill in the human-readable detail line for a tagged element."""
81
+ if enc in spec.SPARSE_TYPES:
82
+ shape = entry.attrs.get("shape", None)
83
+ shape_str = f"{int(shape[0])}x{int(shape[1])}" if shape is not None else "?"
84
+ kind = enc.replace("_matrix", "").upper()
85
+ result["details"] = f"Sparse {kind} matrix {shape_str}"
86
+ return
87
+
88
+ if enc == spec.CATEGORICAL:
89
+ result["ordered"] = bool(spec.decode_attr(entry.attrs.get("ordered", False)))
90
+ n_codes = _safe_len(entry.get("codes"))
91
+ n_cats = _safe_len(entry.get("categories"))
92
+ order = ", ordered" if result["ordered"] else ""
93
+ result["details"] = f"Categorical [{n_codes} values, {n_cats} categories{order}]"
94
+ return
95
+
96
+ if enc == spec.DATAFRAME:
97
+ cols = dataframe_columns(entry)
98
+ legacy = " (legacy __categories)" if "__categories" in entry else ""
99
+ result["details"] = f"DataFrame with {len(cols)} columns{legacy}"
100
+ return
101
+
102
+ if enc in spec.MASKED_TYPES:
103
+ n = _safe_len(entry.get("values"))
104
+ na = spec.decode_attr(entry.attrs.get("na-value", None))
105
+ na_str = f", na-value={na}" if na is not None else ""
106
+ result["details"] = f"Nullable array [{n} values] ({enc}{na_str})"
107
+ return
108
+
109
+ if enc == spec.STRING:
110
+ result["details"] = "String scalar"
111
+ return
112
+
113
+ if enc == spec.NUMERIC_SCALAR:
114
+ result["details"] = f"Numeric scalar ({entry.dtype})"
115
+ return
116
+
117
+ if enc == spec.NULL:
118
+ result["details"] = "Null value"
119
+ return
120
+
121
+ if enc == spec.AWKWARD_ARRAY:
122
+ result["details"] = f"Awkward array (length={entry.attrs.get('length', '?')})"
123
+ return
124
+
125
+ if enc == spec.STRING_ARRAY:
126
+ n = entry.shape[0] if getattr(entry, "shape", None) else "?"
127
+ result["details"] = f"String array [{n}]"
128
+ return
129
+
130
+ if enc in spec.MAPPING_TYPES:
131
+ result["details"] = f"Group with {len(list(entry.keys()))} keys"
132
+ return
133
+
134
+ if enc == spec.ARRAY:
135
+ result["details"] = _array_details(entry)
136
+
137
+
138
+ def _safe_len(obj: Any) -> Any:
139
+ if obj is None:
140
+ return "?"
141
+ shape = getattr(obj, "shape", None)
142
+ return shape[0] if shape else "?"
143
+
144
+
145
+ def _array_details(entry: Any) -> str:
146
+ if entry.shape == ():
147
+ return f"Scalar value ({entry.dtype})"
148
+ if entry.ndim == 1:
149
+ return f"1D array [{entry.shape[0]}] ({entry.dtype})"
150
+ if entry.ndim == 2:
151
+ return f"Dense matrix {entry.shape[0]}x{entry.shape[1]} ({entry.dtype})"
152
+ return f"{entry.ndim}D array {entry.shape} ({entry.dtype})"
153
+
154
+
155
+ def _infer_untagged(entry: Any, result: Dict[str, Any]) -> Dict[str, Any]:
156
+ """Classify an element that carries no encoding attributes (anndata <= 0.7)."""
157
+ if is_dataset(entry):
158
+ if "categories" in entry.attrs:
159
+ result["type"] = "categorical"
160
+ result["export_as"] = "csv"
161
+ result["version"] = result["version"] or "0.1.0"
162
+ n_cats = "?"
163
+ if is_hdf5_dataset(entry):
164
+ try:
165
+ n_cats = entry.file[entry.attrs["categories"]].shape[0]
166
+ except Exception:
167
+ n_cats = "?"
168
+ result["details"] = (
169
+ f"Legacy categorical [{entry.shape[0]} values, {n_cats} categories]"
170
+ )
171
+ return result
172
+
173
+ if entry.shape == ():
174
+ result["type"] = "scalar"
175
+ result["export_as"] = "json"
176
+ result["details"] = f"Scalar value ({entry.dtype})"
177
+ return result
178
+
179
+ result["type"] = "dense-matrix" if entry.ndim == 2 else "array"
180
+ result["export_as"] = "npy"
181
+ result["details"] = _array_details(entry)
182
+ return result
183
+
184
+ if is_group(entry):
185
+ if "codes" in entry and "categories" in entry:
186
+ result["type"] = "categorical"
187
+ result["export_as"] = "csv"
188
+ result["details"] = (
189
+ f"Categorical [{_safe_len(entry.get('codes'))} values, "
190
+ f"{_safe_len(entry.get('categories'))} categories]"
191
+ )
192
+ return result
193
+
194
+ if "values" in entry and "mask" in entry:
195
+ result["type"] = "nullable-array"
196
+ result["export_as"] = "csv"
197
+ result["details"] = f"Nullable array [{_safe_len(entry.get('values'))} values]"
198
+ return result
199
+
200
+ if "_index" in entry.attrs or "obs_names" in entry or "var_names" in entry:
201
+ result["type"] = "dataframe"
202
+ result["export_as"] = "csv"
203
+ result["version"] = result["version"] or "0.1.0"
204
+ legacy = " (legacy v0.1.0)" if "__categories" in entry else ""
205
+ result["details"] = (
206
+ f"DataFrame with {len(dataframe_columns(entry))} columns{legacy}"
207
+ )
208
+ return result
209
+
210
+ result["type"] = "dict"
211
+ result["export_as"] = "json"
212
+ result["details"] = f"Group with {len(list(entry.keys()))} keys"
213
+ return result
214
+
215
+ return result
216
+
217
+
218
+ def format_type_info(info: Dict[str, Any]) -> str:
219
+ type_colors = {
220
+ "anndata": "cyan",
221
+ "raw": "cyan",
222
+ "nullable-array": "blue",
223
+ "string-array": "green",
224
+ "null": "dim",
225
+ "awkward-array": "magenta",
226
+ "dataframe": "green",
227
+ "sparse-matrix": "magenta",
228
+ "dense-matrix": "blue",
229
+ "array": "blue",
230
+ "dict": "yellow",
231
+ "categorical": "green",
232
+ "scalar": "white",
233
+ "unknown": "red",
234
+ }
235
+
236
+ color = type_colors.get(info["type"], "white")
237
+ return f"[{color}]<{info['type']}>[/]"
238
+
239
+
240
+ def axis_len(file: Any, axis: str) -> int:
241
+ """Number of rows along `axis` ("obs" or "var").
242
+
243
+ Resolves the index through :func:`resolve_index`, so this works whether the
244
+ index is a plain dataset or -- as anndata >= 0.11 writes it -- a
245
+ `nullable-string-array` group of `values` and `mask`.
246
+ """
247
+ if axis not in file:
248
+ raise KeyError(f"'{axis}' not found in the file.")
249
+
250
+ group = file[axis]
251
+ if not is_group(group):
252
+ raise TypeError(f"'{axis}' is not a group.")
253
+
254
+ if axis not in ("obs", "var"):
255
+ raise ValueError(f"Invalid axis '{axis}'. Must be 'obs' or 'var'.")
256
+
257
+ index, index_name = resolve_index(group, axis)
258
+ try:
259
+ return element_len(index)
260
+ except (TypeError, ValueError) as exc:
261
+ raise ValueError(
262
+ f"Cannot determine length of '{axis}': index {index_name!r} "
263
+ f"has no usable length ({exc})."
264
+ ) from exc
265
+
266
+
267
+ def get_axis_group(file: Any, axis: str) -> Tuple[Any, int, str]:
268
+ """Return the ``(group, length, index_name)`` triple for an axis."""
269
+ if axis not in ("obs", "var"):
270
+ raise ValueError("axis must be 'obs' or 'var'.")
271
+ if axis not in file:
272
+ raise KeyError(f"'{axis}' not found in the file.")
273
+
274
+ group = file[axis]
275
+ _, index_name = resolve_index(group, axis)
276
+ return group, axis_len(file, axis), index_name
adata/core/query.py ADDED
@@ -0,0 +1,268 @@
1
+ """A small predicate language for selecting obs/var rows.
2
+
3
+ Deliberately not SQL. The point is to express the common filters -- one or two
4
+ comparisons on annotation columns -- without pulling a query engine into a tool
5
+ whose selling point is streaming with light dependencies. Anything more
6
+ involved is better served by exporting to CSV and using duckdb.
7
+
8
+ Grammar::
9
+
10
+ expr := or_expr
11
+ or_expr := and_expr ( "or" and_expr )*
12
+ and_expr:= term ( "and" term )*
13
+ term := "not" term | "(" expr ")" | comparison
14
+ comparison := IDENT OP VALUE
15
+ OP := == | != | < | <= | > | >= | in | not in
16
+
17
+ Values are bare words, quoted strings, or comma-separated lists for `in`.
18
+ Comparison against a column is done on its string form for equality and
19
+ membership, and numerically for the ordering operators.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import dataclass
26
+ from typing import Any, Callable, Dict, List, Optional, Sequence
27
+
28
+ import numpy as np
29
+
30
+ _TOKEN = re.compile(
31
+ r"""
32
+ \s*(?:
33
+ (?P<lparen>\()
34
+ | (?P<rparen>\))
35
+ | (?P<op>==|!=|<=|>=|<|>)
36
+ | (?P<word>"[^"]*"|'[^']*'|[^\s()]+)
37
+ )
38
+ """,
39
+ re.VERBOSE,
40
+ )
41
+
42
+ _KEYWORDS = {"and", "or", "not", "in"}
43
+
44
+
45
+ class QueryError(ValueError):
46
+ """Raised when a query cannot be parsed or refers to a missing column."""
47
+
48
+
49
+ @dataclass
50
+ class Token:
51
+ kind: str
52
+ text: str
53
+
54
+
55
+ def tokenize(expr: str) -> List[Token]:
56
+ tokens: List[Token] = []
57
+ pos = 0
58
+ while pos < len(expr):
59
+ match = _TOKEN.match(expr, pos)
60
+ if match is None:
61
+ if expr[pos:].strip() == "":
62
+ break
63
+ raise QueryError(f"Cannot parse query at {expr[pos:]!r}")
64
+ pos = match.end()
65
+ for kind in ("lparen", "rparen", "op", "word"):
66
+ text = match.group(kind)
67
+ if text is None:
68
+ continue
69
+ if kind == "word" and text.lower() in _KEYWORDS:
70
+ tokens.append(Token(text.lower(), text.lower()))
71
+ else:
72
+ tokens.append(Token(kind, text))
73
+ break
74
+ return tokens
75
+
76
+
77
+ def _unquote(text: str) -> str:
78
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
79
+ return text[1:-1]
80
+ return text
81
+
82
+
83
+ #: A predicate maps a column reader to a boolean mask over the chunk.
84
+ Predicate = Callable[[Dict[str, List[str]]], np.ndarray]
85
+
86
+
87
+ class _Parser:
88
+ def __init__(self, tokens: Sequence[Token]) -> None:
89
+ self.tokens = list(tokens)
90
+ self.pos = 0
91
+
92
+ def peek(self) -> Optional[Token]:
93
+ return self.tokens[self.pos] if self.pos < len(self.tokens) else None
94
+
95
+ def take(self) -> Token:
96
+ token = self.peek()
97
+ if token is None:
98
+ raise QueryError("Unexpected end of query.")
99
+ self.pos += 1
100
+ return token
101
+
102
+ def parse(self) -> Predicate:
103
+ node = self.parse_or()
104
+ if self.peek() is not None:
105
+ raise QueryError(f"Unexpected {self.peek().text!r} in query.")
106
+ return node
107
+
108
+ def parse_or(self) -> Predicate:
109
+ left = self.parse_and()
110
+ while self.peek() is not None and self.peek().kind == "or":
111
+ self.take()
112
+ right = self.parse_and()
113
+ left = _combine(left, right, np.logical_or)
114
+ return left
115
+
116
+ def parse_and(self) -> Predicate:
117
+ left = self.parse_term()
118
+ while self.peek() is not None and self.peek().kind == "and":
119
+ self.take()
120
+ right = self.parse_term()
121
+ left = _combine(left, right, np.logical_and)
122
+ return left
123
+
124
+ def parse_term(self) -> Predicate:
125
+ token = self.peek()
126
+ if token is None:
127
+ raise QueryError("Unexpected end of query.")
128
+
129
+ if token.kind == "not":
130
+ self.take()
131
+ inner = self.parse_term()
132
+ return lambda cols: np.logical_not(inner(cols))
133
+
134
+ if token.kind == "lparen":
135
+ self.take()
136
+ inner = self.parse_or()
137
+ closing = self.take()
138
+ if closing.kind != "rparen":
139
+ raise QueryError("Unbalanced parentheses in query.")
140
+ return inner
141
+
142
+ return self.parse_comparison()
143
+
144
+ def parse_comparison(self) -> Predicate:
145
+ name_token = self.take()
146
+ if name_token.kind != "word":
147
+ raise QueryError(f"Expected a column name, got {name_token.text!r}.")
148
+ column = _unquote(name_token.text)
149
+
150
+ op_token = self.take()
151
+ if op_token.kind == "not":
152
+ following = self.take()
153
+ if following.kind != "in":
154
+ raise QueryError("Expected 'in' after 'not'.")
155
+ return _membership(column, self._take_list(), negate=True)
156
+ if op_token.kind == "in":
157
+ return _membership(column, self._take_list(), negate=False)
158
+ if op_token.kind != "op":
159
+ raise QueryError(
160
+ f"Expected a comparison operator after {column!r}, "
161
+ f"got {op_token.text!r}."
162
+ )
163
+
164
+ value_token = self.take()
165
+ if value_token.kind != "word":
166
+ raise QueryError(f"Expected a value, got {value_token.text!r}.")
167
+ return _comparison(column, op_token.text, _unquote(value_token.text))
168
+
169
+ def _take_list(self) -> List[str]:
170
+ token = self.take()
171
+ if token.kind == "lparen":
172
+ items: List[str] = []
173
+ while True:
174
+ nxt = self.take()
175
+ if nxt.kind == "rparen":
176
+ break
177
+ items.extend(
178
+ v.strip() for v in _unquote(nxt.text).split(",") if v.strip()
179
+ )
180
+ return items
181
+ if token.kind != "word":
182
+ raise QueryError(f"Expected a value list, got {token.text!r}.")
183
+ return [v.strip() for v in _unquote(token.text).split(",") if v.strip()]
184
+
185
+
186
+ def _combine(left: Predicate, right: Predicate, op: Any) -> Predicate:
187
+ return lambda cols: op(left(cols), right(cols))
188
+
189
+
190
+ def _column(cols: Dict[str, List[str]], name: str) -> np.ndarray:
191
+ if name not in cols:
192
+ raise QueryError(
193
+ f"Column {name!r} not found. Available: {', '.join(sorted(cols))}"
194
+ )
195
+ return np.asarray(cols[name], dtype=str)
196
+
197
+
198
+ def _as_float(values: np.ndarray, column: str) -> np.ndarray:
199
+ """Parse a string column as floats, treating unparseable entries as NaN."""
200
+ out = np.full(len(values), np.nan, dtype=np.float64)
201
+ for i, v in enumerate(values):
202
+ try:
203
+ out[i] = float(v)
204
+ except (TypeError, ValueError):
205
+ continue
206
+ return out
207
+
208
+
209
+ def _comparison(column: str, op: str, value: str) -> Predicate:
210
+ def run(cols: Dict[str, List[str]]) -> np.ndarray:
211
+ values = _column(cols, column)
212
+ if op == "==":
213
+ return values == value
214
+ if op == "!=":
215
+ return values != value
216
+
217
+ try:
218
+ threshold = float(value)
219
+ except ValueError as exc:
220
+ raise QueryError(
221
+ f"Operator {op!r} needs a number, but got {value!r}."
222
+ ) from exc
223
+
224
+ numeric = _as_float(values, column)
225
+ with np.errstate(invalid="ignore"):
226
+ if op == "<":
227
+ return numeric < threshold
228
+ if op == "<=":
229
+ return numeric <= threshold
230
+ if op == ">":
231
+ return numeric > threshold
232
+ return numeric >= threshold
233
+
234
+ return run
235
+
236
+
237
+ def _membership(column: str, options: Sequence[str], negate: bool) -> Predicate:
238
+ wanted = set(options)
239
+
240
+ def run(cols: Dict[str, List[str]]) -> np.ndarray:
241
+ values = _column(cols, column)
242
+ mask = np.isin(values, list(wanted))
243
+ return np.logical_not(mask) if negate else mask
244
+
245
+ return run
246
+
247
+
248
+ def compile_query(expr: str) -> Predicate:
249
+ """Compile a query string into a predicate over a chunk of columns."""
250
+ tokens = tokenize(expr)
251
+ if not tokens:
252
+ raise QueryError("Query is empty.")
253
+ return _Parser(tokens).parse()
254
+
255
+
256
+ def referenced_columns(expr: str) -> List[str]:
257
+ """Names that look like column references, so only those need reading."""
258
+ tokens = tokenize(expr)
259
+ names: List[str] = []
260
+ for i, token in enumerate(tokens):
261
+ if token.kind != "word":
262
+ continue
263
+ following = tokens[i + 1] if i + 1 < len(tokens) else None
264
+ if following is not None and following.kind in ("op", "in", "not"):
265
+ name = _unquote(token.text)
266
+ if name not in names:
267
+ names.append(name)
268
+ return names
adata/core/read.py ADDED
@@ -0,0 +1,28 @@
1
+ """Column reading. The implementation now lives in :mod:`adata.elements.read`.
2
+
3
+ Kept as a stable import path for existing callers and tests.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from adata.elements.read import (
9
+ col_chunk_as_strings,
10
+ decode_str_array,
11
+ element_len,
12
+ read_categorical_column,
13
+ read_masked_column,
14
+ read_str_all,
15
+ read_str_chunk,
16
+ resolve_index,
17
+ )
18
+
19
+ __all__ = [
20
+ "col_chunk_as_strings",
21
+ "decode_str_array",
22
+ "element_len",
23
+ "read_categorical_column",
24
+ "read_masked_column",
25
+ "read_str_all",
26
+ "read_str_chunk",
27
+ "resolve_index",
28
+ ]
adata/core/select.py ADDED
@@ -0,0 +1,122 @@
1
+ """Selecting rows of an axis by column value, without loading the store.
2
+
3
+ Both `subset --query` and `split --by` need the same thing: walk an axis's
4
+ annotation columns in chunks and decide which rows to keep. Only the columns a
5
+ query actually mentions are read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ import numpy as np
13
+
14
+ from adata.core.info import get_axis_group
15
+ from adata.core.query import QueryError, compile_query, referenced_columns
16
+ from adata.elements.read import col_chunk_as_strings, dataframe_columns
17
+
18
+
19
+ def _available_columns(group: Any, index_name: str) -> List[str]:
20
+ cols = dataframe_columns(group, index_name)
21
+ return cols + [index_name]
22
+
23
+
24
+ def _read_chunk(
25
+ group: Any,
26
+ columns: List[str], start: int, end: int, cache: Dict
27
+ ) -> Dict[str, List[str]]:
28
+ return {
29
+ name: col_chunk_as_strings(group, name, start, end, cache)
30
+ for name in columns
31
+ }
32
+
33
+
34
+ def select_indices(
35
+ root: Any,
36
+ axis: str,
37
+ expr: str,
38
+ *,
39
+ chunk_size: int = 100_000,
40
+ ) -> np.ndarray:
41
+ """Row indices of `axis` matching the query `expr`.
42
+
43
+ Only the columns the query references are read, so filtering on one
44
+ annotation does not touch the rest of the frame.
45
+ """
46
+ group, n_rows, index_name = get_axis_group(root, axis)
47
+ predicate = compile_query(expr)
48
+
49
+ wanted = referenced_columns(expr)
50
+ available = _available_columns(group, index_name)
51
+ missing = [c for c in wanted if c not in available]
52
+ if missing:
53
+ raise QueryError(
54
+ f"Column(s) {', '.join(missing)} not found in '{axis}'. "
55
+ f"Available: {', '.join(sorted(available))}"
56
+ )
57
+
58
+ cache: Dict = {}
59
+ kept: List[np.ndarray] = []
60
+ for start in range(0, n_rows, chunk_size):
61
+ end = min(start + chunk_size, n_rows)
62
+ mask = np.asarray(
63
+ predicate(_read_chunk(group, wanted, start, end, cache)), dtype=bool
64
+ )
65
+ if mask.any():
66
+ kept.append(np.nonzero(mask)[0] + start)
67
+
68
+ if not kept:
69
+ return np.empty(0, dtype=np.int64)
70
+ return np.concatenate(kept).astype(np.int64)
71
+
72
+
73
+ def group_indices(
74
+ root: Any,
75
+ axis: str,
76
+ column: str,
77
+ *,
78
+ chunk_size: int = 100_000,
79
+ ) -> Tuple[Dict[str, np.ndarray], List[str]]:
80
+ """Group `axis` row indices by the value of `column`.
81
+
82
+ Returns the groups and the distinct labels in order of first appearance,
83
+ so output ordering follows the data rather than the hash of the labels.
84
+ """
85
+ group, n_rows, index_name = get_axis_group(root, axis)
86
+
87
+ available = _available_columns(group, index_name)
88
+ if column not in available:
89
+ raise KeyError(
90
+ f"Column {column!r} not found in '{axis}'. "
91
+ f"Available: {', '.join(sorted(available))}"
92
+ )
93
+
94
+ cache: Dict = {}
95
+ buckets: Dict[str, List[np.ndarray]] = {}
96
+ order: List[str] = []
97
+
98
+ for start in range(0, n_rows, chunk_size):
99
+ end = min(start + chunk_size, n_rows)
100
+ values = np.asarray(
101
+ col_chunk_as_strings(group, column, start, end, cache), dtype=str
102
+ )
103
+ for label in dict.fromkeys(values.tolist()):
104
+ if label not in buckets:
105
+ buckets[label] = []
106
+ order.append(label)
107
+ buckets[label].append(np.nonzero(values == label)[0] + start)
108
+
109
+ return (
110
+ {k: np.concatenate(v).astype(np.int64) for k, v in buckets.items()},
111
+ order,
112
+ )
113
+
114
+
115
+ def read_names(root: Any, axis: str, indices: Optional[np.ndarray] = None) -> List[str]:
116
+ """Read an axis's index labels, optionally only at `indices`."""
117
+ group, n_rows, index_name = get_axis_group(root, axis)
118
+ cache: Dict = {}
119
+ names = col_chunk_as_strings(group, index_name, 0, n_rows, cache)
120
+ if indices is None:
121
+ return names
122
+ return [names[i] for i in indices]