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.
@@ -0,0 +1,255 @@
1
+ """Writing AnnData elements in the current on-disk spec.
2
+
3
+ Every element this tool creates is tagged with its `encoding-type` and
4
+ `encoding-version` here, so a store the CLI writes is indistinguishable from
5
+ one anndata wrote. Text always goes out as variable-length UTF-8 -- fixed-width
6
+ bytes are readable by anndata but are not what `string-array` specifies, and on
7
+ Zarr v3 they produce a dtype other libraries cannot read.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Iterable, Optional, Sequence
13
+
14
+ import numpy as np
15
+
16
+ from adata.elements import spec
17
+ from adata.storage import create_dataset, is_zarr_group
18
+
19
+
20
+ def _backend_of(parent: Any) -> str:
21
+ return "zarr" if is_zarr_group(parent) else "hdf5"
22
+
23
+
24
+ def _replace(parent: Any, name: str) -> None:
25
+ """Remove an existing member so it can be rewritten."""
26
+ if name in parent:
27
+ del parent[name]
28
+
29
+
30
+ def write_mapping(parent: Any, name: str, replace: bool = False) -> Any:
31
+ """Create (or fetch) a group tagged as a `dict` mapping."""
32
+ if replace:
33
+ _replace(parent, name)
34
+ group = parent[name] if name in parent else parent.create_group(name)
35
+ spec.set_encoding(group, spec.DICT)
36
+ return group
37
+
38
+
39
+ def write_string_array(
40
+ parent: Any, name: str, values: Iterable[Any], replace: bool = False
41
+ ) -> Any:
42
+ """Write a `string-array`: variable-length UTF-8 on either backend.
43
+
44
+ Multi-dimensional input keeps its shape; `list()`-ing it first would
45
+ silently flatten a 2-D array of labels into a vector.
46
+ """
47
+ if replace:
48
+ _replace(parent, name)
49
+ data = (
50
+ values
51
+ if isinstance(values, np.ndarray)
52
+ else np.asarray(list(values), dtype=object)
53
+ )
54
+ ds = create_dataset(parent, name, data=data)
55
+ spec.set_encoding(ds, spec.STRING_ARRAY)
56
+ return ds
57
+
58
+
59
+ def write_dense(
60
+ parent: Any, name: str, data: Any, replace: bool = False, **kwargs: Any
61
+ ) -> Any:
62
+ """Write a dense numeric `array`."""
63
+ if replace:
64
+ _replace(parent, name)
65
+ ds = create_dataset(parent, name, data=np.asarray(data), **kwargs)
66
+ spec.set_encoding(ds, spec.ARRAY)
67
+ return ds
68
+
69
+
70
+ def write_scalar(parent: Any, name: str, value: Any, replace: bool = False) -> Any:
71
+ """Write a 0-d scalar as `string` or `numeric-scalar`, per its type."""
72
+ if replace:
73
+ _replace(parent, name)
74
+
75
+ if isinstance(value, (str, bytes, np.str_, np.bytes_)):
76
+ text = value.decode("utf-8") if isinstance(value, bytes) else str(value)
77
+ ds = create_dataset(parent, name, data=np.asarray(text, dtype=object))
78
+ spec.set_encoding(ds, spec.STRING)
79
+ return ds
80
+
81
+ arr = np.asarray(value)
82
+ ds = create_dataset(parent, name, data=arr)
83
+ spec.set_encoding(ds, spec.NUMERIC_SCALAR)
84
+ return ds
85
+
86
+
87
+ def write_null(parent: Any, name: str, replace: bool = False) -> Any:
88
+ """Write an explicit null, matching how anndata >= 0.12 encodes `None`.
89
+
90
+ HDF5 uses a null dataspace (`h5py.Empty`); Zarr uses a 0-d boolean array.
91
+ Both carry `encoding-type: null`, so `None` round-trips instead of needing
92
+ an invented marker attribute.
93
+ """
94
+ if replace:
95
+ _replace(parent, name)
96
+
97
+ if is_zarr_group(parent):
98
+ ds = parent.create_array(name, shape=(), dtype=bool)
99
+ else:
100
+ import h5py
101
+
102
+ ds = parent.create_dataset(name, data=h5py.Empty("f4"))
103
+ spec.set_encoding(ds, spec.NULL)
104
+ return ds
105
+
106
+
107
+ def write_categorical(
108
+ parent: Any,
109
+ name: str,
110
+ codes: Any,
111
+ categories: Sequence[Any],
112
+ ordered: bool = False,
113
+ replace: bool = False,
114
+ ) -> Any:
115
+ """Write a `categorical` group of `categories` + `codes`.
116
+
117
+ A code of -1 denotes a missing value, as the spec requires.
118
+ """
119
+ if replace:
120
+ _replace(parent, name)
121
+ group = parent.create_group(name)
122
+ spec.set_encoding(group, spec.CATEGORICAL)
123
+ group.attrs["ordered"] = bool(ordered)
124
+
125
+ write_string_array(group, "categories", categories)
126
+ write_dense(group, "codes", np.asarray(codes, dtype=_codes_dtype(len(categories))))
127
+ return group
128
+
129
+
130
+ def _codes_dtype(n_categories: int) -> Any:
131
+ """Smallest signed integer dtype that can index `n_categories` plus -1."""
132
+ if n_categories < 128:
133
+ return np.int8
134
+ if n_categories < 32_768:
135
+ return np.int16
136
+ return np.int32
137
+
138
+
139
+ def write_masked(
140
+ parent: Any,
141
+ name: str,
142
+ values: Any,
143
+ mask: Any,
144
+ enc_type: str,
145
+ na_value: Optional[str] = None,
146
+ replace: bool = False,
147
+ ) -> Any:
148
+ """Write a nullable element as a `values` + `mask` group.
149
+
150
+ A True entry in `mask` marks a missing value.
151
+ """
152
+ if enc_type not in spec.MASKED_TYPES:
153
+ raise ValueError(f"{enc_type!r} is not a masked encoding.")
154
+ if replace:
155
+ _replace(parent, name)
156
+
157
+ group = parent.create_group(name)
158
+ spec.set_encoding(group, enc_type)
159
+ if na_value is not None:
160
+ group.attrs["na-value"] = na_value
161
+
162
+ if enc_type == spec.NULLABLE_STRING_ARRAY:
163
+ write_string_array(group, "values", values)
164
+ else:
165
+ write_dense(group, "values", values)
166
+ write_dense(group, "mask", np.asarray(mask, dtype=bool))
167
+ return group
168
+
169
+
170
+ def write_sparse(
171
+ parent: Any,
172
+ name: str,
173
+ data: Any,
174
+ indices: Any,
175
+ indptr: Any,
176
+ shape: Sequence[int],
177
+ enc_type: str = spec.CSR_MATRIX,
178
+ replace: bool = False,
179
+ ) -> Any:
180
+ """Write a CSR/CSC sparse matrix group."""
181
+ if enc_type not in spec.SPARSE_TYPES:
182
+ raise ValueError(f"{enc_type!r} is not a sparse encoding.")
183
+ if replace:
184
+ _replace(parent, name)
185
+
186
+ group = parent.create_group(name)
187
+ spec.set_encoding(group, enc_type)
188
+ set_shape_attr(group, shape)
189
+
190
+ create_dataset(group, "data", data=np.asarray(data))
191
+ create_dataset(group, "indices", data=np.asarray(indices))
192
+ create_dataset(group, "indptr", data=np.asarray(indptr))
193
+ return group
194
+
195
+
196
+ def set_shape_attr(group: Any, shape: Sequence[int]) -> None:
197
+ """Set a sparse group's `shape`, in the form the backend's attrs accept.
198
+
199
+ Zarr attributes must be JSON-serialisable, so a numpy array cannot be
200
+ stored there; HDF5 conventionally holds an integer array.
201
+ """
202
+ dims = [int(d) for d in shape]
203
+ if is_zarr_group(group):
204
+ group.attrs["shape"] = dims
205
+ else:
206
+ group.attrs["shape"] = np.array(dims, dtype=np.int64)
207
+
208
+
209
+ def write_dataframe_header(
210
+ parent: Any,
211
+ name: str,
212
+ index_values: Iterable[Any],
213
+ column_order: Sequence[str],
214
+ index_name: str = "_index",
215
+ replace: bool = True,
216
+ ) -> Any:
217
+ """Create a `dataframe` group with its index and declared column order.
218
+
219
+ Columns themselves are written afterwards by the caller; `column-order`
220
+ records the authored order so readers do not fall back to the backend's
221
+ own (alphabetical, on HDF5) enumeration.
222
+ """
223
+ if replace:
224
+ _replace(parent, name)
225
+
226
+ group = parent.create_group(name)
227
+ spec.set_encoding(group, spec.DATAFRAME)
228
+ group.attrs["_index"] = index_name
229
+ set_column_order(group, column_order)
230
+ write_string_array(group, index_name, index_values)
231
+ return group
232
+
233
+
234
+ def set_column_order(group: Any, columns: Sequence[str]) -> None:
235
+ """Record a dataframe's column order in the form the backend accepts.
236
+
237
+ Zarr attributes must be JSON, so a plain list is used. HDF5 needs an
238
+ explicit variable-length UTF-8 dtype -- an object array has no native
239
+ HDF5 equivalent, and an empty one cannot be inferred at all.
240
+ """
241
+ names = [str(c) for c in columns]
242
+ if is_zarr_group(group):
243
+ group.attrs["column-order"] = names
244
+ else:
245
+ import h5py
246
+
247
+ group.attrs["column-order"] = np.array(
248
+ names, dtype=h5py.string_dtype(encoding="utf-8")
249
+ )
250
+
251
+
252
+ def ensure_anndata_skeleton(root: Any) -> None:
253
+ """Create the optional mapping groups an AnnData store is expected to have."""
254
+ for key in ("layers", "obsm", "obsp", "varm", "varp", "uns"):
255
+ write_mapping(root, key)
@@ -0,0 +1 @@
1
+ """Format-specific import/export helpers."""
adata/formats/array.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import sys
5
+ from typing import Any, Optional
6
+
7
+ import numpy as np
8
+ from rich.console import Console
9
+
10
+ from adata.formats.common import _get_encoding_type, _resolve
11
+ from adata.formats.validate import validate_dimensions
12
+ from adata.elements.write import write_dense
13
+ from adata.storage import create_dataset, is_dataset, is_group
14
+ from adata.util.path import norm_path
15
+
16
+
17
+ def export_npy(
18
+ root: Any,
19
+ obj: str,
20
+ out: Optional[Path],
21
+ chunk_elements: int,
22
+ console: Console,
23
+ ) -> None:
24
+ """Write a dense array to a .npy file, or to stdout when `out` is None.
25
+
26
+ Writing to a file streams in chunks through a memory-mapped output. Stdout
27
+ is not seekable, so that path materialises the array first and is only
28
+ suitable for arrays that fit in memory.
29
+ """
30
+ h5obj = _resolve(root, obj)
31
+
32
+ if is_group(h5obj):
33
+ enc = _get_encoding_type(h5obj)
34
+ if enc in ("nullable-integer", "nullable-boolean", "nullable-string-array"):
35
+ if "values" not in h5obj:
36
+ raise ValueError(f"Encoded group '{obj}' is missing 'values' dataset.")
37
+ ds = h5obj["values"]
38
+ console.print(f"[dim]Exporting nullable array values from '{obj}'[/]")
39
+ else:
40
+ raise ValueError(
41
+ f"Target '{obj}' is a group with encoding '{enc}'; cannot export as .npy directly."
42
+ )
43
+ elif is_dataset(h5obj):
44
+ ds = h5obj
45
+ else:
46
+ raise ValueError("Target is not an array-like object.")
47
+
48
+ if out is None or str(out) == "-":
49
+ np.save(sys.stdout.buffer, np.asarray(ds[...]), allow_pickle=False)
50
+ sys.stdout.buffer.flush()
51
+ return
52
+
53
+ out.parent.mkdir(parents=True, exist_ok=True)
54
+ mm = np.lib.format.open_memmap(out, mode="w+", dtype=ds.dtype, shape=ds.shape)
55
+ try:
56
+ if ds.shape == ():
57
+ mm[...] = ds[()]
58
+ console.print(f"[green]Wrote[/] {out}")
59
+ return
60
+
61
+ if ds.ndim == 1:
62
+ n = int(ds.shape[0])
63
+ step = max(1, int(chunk_elements))
64
+ with console.status(f"[magenta]Exporting {obj} to {out}...[/]") as status:
65
+ for start in range(0, n, step):
66
+ end = min(start + step, n)
67
+ status.update(
68
+ f"[magenta]Exporting {obj}: {start}-{end} of {n}...[/]"
69
+ )
70
+ mm[start:end] = ds[start:end]
71
+ console.print(f"[green]Wrote[/] {out}")
72
+ return
73
+
74
+ n0 = int(ds.shape[0])
75
+ row_elems = int(np.prod(ds.shape[1:])) if ds.ndim > 1 else 1
76
+ step0 = max(1, int(chunk_elements) // max(1, row_elems))
77
+ with console.status(f"[magenta]Exporting {obj} to {out}...[/]") as status:
78
+ for start in range(0, n0, step0):
79
+ end = min(start + step0, n0)
80
+ status.update(
81
+ f"[magenta]Exporting {obj}: {start}-{end} of {n0}...[/]"
82
+ )
83
+ mm[start:end, ...] = ds[start:end, ...]
84
+ console.print(f"[green]Wrote[/] {out}")
85
+ finally:
86
+ del mm
87
+
88
+
89
+ def import_npy(
90
+ root: Any,
91
+ obj: str,
92
+ input_file: Path,
93
+ console: Console,
94
+ ) -> None:
95
+ obj = norm_path(obj)
96
+ arr = np.load(input_file)
97
+
98
+ validate_dimensions(root, obj, arr.shape, console)
99
+
100
+ parts = obj.split("/")
101
+ parent = root
102
+ for part in parts[:-1]:
103
+ parent = parent[part] if part in parent else parent.create_group(part)
104
+ name = parts[-1]
105
+
106
+ write_dense(parent, name, arr, replace=True)
107
+
108
+ shape_str = "×".join(str(d) for d in arr.shape)
109
+ console.print(f"[green]Imported[/] {shape_str} array into '{obj}'")
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+
7
+ from adata.storage import is_dataset, is_group
8
+ from adata.util.path import norm_path
9
+
10
+
11
+ def _get_encoding_type(group: Any) -> str:
12
+ enc = group.attrs.get("encoding-type", "")
13
+ if isinstance(enc, bytes):
14
+ enc = enc.decode("utf-8")
15
+ return str(enc)
16
+
17
+
18
+ def _resolve(root: Any, obj: str) -> Any:
19
+ obj = norm_path(obj)
20
+ if obj not in root:
21
+ raise KeyError(f"'{obj}' not found in the file.")
22
+ return root[obj]
23
+
24
+
25
+ def _check_json_exportable(h5obj: Any, max_elements: int, path: str = "") -> None:
26
+ if is_dataset(h5obj):
27
+ if h5obj.shape == ():
28
+ return
29
+ n = int(np.prod(h5obj.shape)) if h5obj.shape else 0
30
+ if n > max_elements:
31
+ obj_name = getattr(h5obj, "name", "<object>")
32
+ raise ValueError(
33
+ f"Cannot export to JSON: '{path or obj_name}' has {n} elements "
34
+ f"(max {max_elements}). Use --max-elements to increase limit."
35
+ )
36
+ return
37
+
38
+ if is_group(h5obj):
39
+ enc = _get_encoding_type(h5obj)
40
+ if enc in ("csr_matrix", "csc_matrix"):
41
+ obj_name = getattr(h5obj, "name", "<object>")
42
+ raise ValueError(
43
+ f"Cannot export to JSON: '{path or obj_name}' is a sparse matrix. "
44
+ "Export it as .mtx instead."
45
+ )
46
+
47
+ for key in h5obj.keys():
48
+ child = h5obj[key]
49
+ child_path = f"{path}/{key}" if path else key
50
+ if is_group(child) or is_dataset(child):
51
+ _check_json_exportable(child, max_elements=max_elements, path=child_path)
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import sys
5
+ from contextlib import nullcontext
6
+ from pathlib import Path
7
+ from typing import Any, List, Optional, Tuple
8
+
9
+ import numpy as np
10
+ from rich.console import Console
11
+
12
+ from adata.core.read import col_chunk_as_strings
13
+ from adata.formats.common import _resolve
14
+ from adata.util.path import norm_path
15
+ from adata.elements.read import dataframe_columns, element_len, resolve_index
16
+ from adata.formats.validate import validate_dimensions
17
+ from adata.elements.write import (
18
+ write_categorical,
19
+ write_mapping,
20
+ write_dataframe_header,
21
+ write_dense,
22
+ write_string_array,
23
+ )
24
+ from adata.storage import create_dataset, is_group, is_zarr_group
25
+
26
+
27
+ def export_dataframe(
28
+ root: Any,
29
+ axis: str,
30
+ columns: Optional[List[str]],
31
+ out: Optional[Path],
32
+ chunk_rows: int,
33
+ head: Optional[int],
34
+ console: Console,
35
+ ) -> None:
36
+ """Stream a dataframe group out as CSV.
37
+
38
+ `axis` is any path to a dataframe-encoded group, not only "obs" or "var" --
39
+ a store may hold dataframes in obsm, varm, uns or raw as well.
40
+ """
41
+ group = _resolve(root, axis)
42
+ if not is_group(group):
43
+ raise ValueError(f"'{axis}' is not a group and cannot be exported as CSV.")
44
+
45
+ try:
46
+ index, index_name = resolve_index(group)
47
+ except KeyError as exc:
48
+ raise ValueError(
49
+ f"'{axis}' is not a dataframe: it has no index. "
50
+ "CSV export needs a dataframe-encoded group such as 'obs', 'var' "
51
+ "or 'raw/var'."
52
+ ) from exc
53
+ n_rows = element_len(index)
54
+
55
+ if isinstance(index_name, bytes):
56
+ index_name = index_name.decode("utf-8")
57
+
58
+ if columns:
59
+ col_names = list(columns)
60
+ else:
61
+ col_names = dataframe_columns(group, index_name)
62
+
63
+ if index_name not in col_names:
64
+ col_names.insert(0, index_name)
65
+ else:
66
+ col_names = [index_name] + [c for c in col_names if c != index_name]
67
+
68
+ if head is not None and head > 0:
69
+ n_rows = min(n_rows, head)
70
+
71
+ if out is None or str(out) == "-":
72
+ out_fh = sys.stdout
73
+ else:
74
+ out_fh = open(out, "w", newline="", encoding="utf-8")
75
+ writer = csv.writer(out_fh)
76
+
77
+ try:
78
+ writer.writerow(col_names)
79
+ cat_cache = {}
80
+
81
+ use_status = out_fh is not sys.stdout
82
+ status_ctx = (
83
+ console.status(f"[magenta]Exporting {axis} table to {out}...[/]")
84
+ if use_status
85
+ else nullcontext()
86
+ )
87
+
88
+ with status_ctx as status:
89
+ for start in range(0, n_rows, chunk_rows):
90
+ end = min(start + chunk_rows, n_rows)
91
+ if use_status and status:
92
+ status.update(
93
+ f"[magenta]Exporting rows {start}-{end} of {n_rows}...[/]"
94
+ )
95
+ cols_data: List[List[str]] = []
96
+ for col in col_names:
97
+ cols_data.append(
98
+ col_chunk_as_strings(group, col, start, end, cat_cache)
99
+ )
100
+ for row_idx in range(end - start):
101
+ row = [
102
+ cols_data[col_idx][row_idx]
103
+ for col_idx in range(len(col_names))
104
+ ]
105
+ writer.writerow(row)
106
+ finally:
107
+ if out_fh is not sys.stdout:
108
+ out_fh.close()
109
+
110
+
111
+ def _read_csv(
112
+ input_file: Path,
113
+ index_column: Optional[str],
114
+ ) -> Tuple[List[dict], List[str], List[str], str]:
115
+ with open(input_file, "r", encoding="utf-8", newline="") as f:
116
+ reader = csv.DictReader(f)
117
+ if reader.fieldnames is None:
118
+ raise ValueError("CSV file has no header.")
119
+ fieldnames = list(reader.fieldnames)
120
+
121
+ if index_column:
122
+ if index_column not in fieldnames:
123
+ raise ValueError(
124
+ f"Index column '{index_column}' not found in CSV. "
125
+ f"Available columns: {', '.join(fieldnames)}"
126
+ )
127
+ idx_col = index_column
128
+ else:
129
+ idx_col = fieldnames[0]
130
+
131
+ rows = list(reader)
132
+
133
+ index_values = [row[idx_col] for row in rows]
134
+ data_columns = [c for c in fieldnames if c != idx_col]
135
+
136
+ return rows, data_columns, index_values, idx_col
137
+
138
+
139
+ def _looks_categorical(values: List[str], n_rows: int) -> bool:
140
+ """Heuristic: few enough repeated labels that a category is the better fit.
141
+
142
+ Mirrors the usual pandas rule of thumb -- a column whose distinct values
143
+ number well under half its rows is almost always a label, not free text.
144
+ Callers can always override explicitly.
145
+ """
146
+ n_distinct = len(set(values))
147
+ return 0 < n_distinct <= min(1000, max(1, n_rows // 2))
148
+
149
+
150
+ def _write_column(
151
+ group: Any,
152
+ name: str,
153
+ values: List[str],
154
+ n_rows: int,
155
+ categorical: bool,
156
+ ) -> None:
157
+ """Write one CSV column, choosing the narrowest faithful encoding."""
158
+ try:
159
+ write_dense(group, name, np.array(values, dtype=np.int64))
160
+ return
161
+ except (ValueError, TypeError, OverflowError):
162
+ pass
163
+
164
+ try:
165
+ write_dense(group, name, np.array(values, dtype=np.float64))
166
+ return
167
+ except (ValueError, TypeError):
168
+ pass
169
+
170
+ if categorical:
171
+ categories = sorted(set(values))
172
+ lookup = {c: i for i, c in enumerate(categories)}
173
+ write_categorical(
174
+ group, name, [lookup[v] for v in values], categories, ordered=False
175
+ )
176
+ return
177
+
178
+ write_string_array(group, name, values)
179
+
180
+
181
+ def import_dataframe(
182
+ root: Any,
183
+ obj: str,
184
+ input_file: Path,
185
+ index_column: Optional[str],
186
+ console: Console,
187
+ categorical: Optional[List[str]] = None,
188
+ auto_categorical: bool = True,
189
+ ) -> None:
190
+ """Replace a dataframe at `obj` with the contents of a CSV file.
191
+
192
+ `obj` is any dataframe path, not only "obs" or "var". Columns that parse
193
+ cleanly as integers or floats become numeric arrays; the rest become
194
+ either a `categorical` or a `string-array`. `categorical` names columns to
195
+ force, and `auto_categorical` applies the heuristic to the remainder.
196
+ """
197
+ obj = norm_path(obj)
198
+ rows, data_columns, index_values, _ = _read_csv(input_file, index_column)
199
+ n_rows = len(rows)
200
+
201
+ validate_dimensions(root, obj, (n_rows,), console)
202
+
203
+ parts = obj.split("/")
204
+ parent = root
205
+ for part in parts[:-1]:
206
+ parent = parent[part] if part in parent else write_mapping(parent, part)
207
+
208
+ group = write_dataframe_header(
209
+ parent, parts[-1], index_values, data_columns, index_name="_index"
210
+ )
211
+
212
+ forced = set(categorical or ())
213
+ n_categorical = 0
214
+ for col in data_columns:
215
+ values = [row[col] for row in rows]
216
+ as_categorical = col in forced or (
217
+ auto_categorical and _looks_categorical(values, n_rows)
218
+ )
219
+ _write_column(group, col, values, n_rows, as_categorical)
220
+ if as_categorical and not _is_numeric(group, col):
221
+ n_categorical += 1
222
+
223
+ detail = f" ({n_categorical} categorical)" if n_categorical else ""
224
+ console.print(
225
+ f"[green]Imported[/] {n_rows} rows x {len(data_columns)} columns "
226
+ f"into '{obj}'{detail}"
227
+ )
228
+
229
+
230
+ def _is_numeric(group: Any, col: str) -> bool:
231
+ """True when the written column ended up as a numeric array."""
232
+ from adata.elements import spec
233
+
234
+ return spec.encoding_type(group[col]) == spec.ARRAY