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/__init__.py +10 -0
- adata/cli.py +1139 -0
- adata/commands/__init__.py +8 -0
- adata/commands/concat.py +21 -0
- adata/commands/create.py +93 -0
- adata/commands/export.py +105 -0
- adata/commands/import_data.py +144 -0
- adata/commands/info.py +199 -0
- adata/commands/ls.py +114 -0
- adata/commands/split.py +152 -0
- adata/commands/subset.py +17 -0
- adata/core/__init__.py +1 -0
- adata/core/concat.py +1084 -0
- adata/core/info.py +276 -0
- adata/core/query.py +268 -0
- adata/core/read.py +28 -0
- adata/core/select.py +122 -0
- adata/core/subset.py +767 -0
- adata/elements/__init__.py +10 -0
- adata/elements/read.py +310 -0
- adata/elements/spec.py +125 -0
- adata/elements/strings.py +90 -0
- adata/elements/write.py +255 -0
- adata/formats/__init__.py +1 -0
- adata/formats/array.py +109 -0
- adata/formats/common.py +51 -0
- adata/formats/dataframe.py +234 -0
- adata/formats/image.py +85 -0
- adata/formats/json_data.py +196 -0
- adata/formats/sparse.py +253 -0
- adata/formats/validate.py +180 -0
- adata/info.py +3 -0
- adata/py.typed +0 -0
- adata/read.py +19 -0
- adata/storage/__init__.py +529 -0
- adata/util/__init__.py +1 -0
- adata/util/path.py +9 -0
- pyadata_cli-0.5.0.dist-info/METADATA +135 -0
- pyadata_cli-0.5.0.dist-info/RECORD +42 -0
- pyadata_cli-0.5.0.dist-info/WHEEL +4 -0
- pyadata_cli-0.5.0.dist-info/entry_points.txt +4 -0
- pyadata_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
adata/elements/write.py
ADDED
|
@@ -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}'")
|
adata/formats/common.py
ADDED
|
@@ -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
|