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
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""AnnData on-disk element handling: the spec, and reading/writing each element.
|
|
2
|
+
|
|
3
|
+
`spec` holds the encoding constants, `read` turns any on-disk layout into
|
|
4
|
+
usable values, `write` emits the current spec, and `strings` reconciles the two
|
|
5
|
+
backends' incompatible spellings of text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from adata.elements import read, spec, strings, write
|
|
9
|
+
|
|
10
|
+
__all__ = ["read", "spec", "strings", "write"]
|
adata/elements/read.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Reading AnnData elements, in every layout the spec has ever used.
|
|
2
|
+
|
|
3
|
+
Elements that logically hold one column of text are stored four different ways
|
|
4
|
+
depending on which anndata wrote the file:
|
|
5
|
+
|
|
6
|
+
* a plain dataset of bytes or vlen str (`string-array`, or untagged in 0.7.x),
|
|
7
|
+
* a group of `categories` + `codes` (`categorical`),
|
|
8
|
+
* a dataset of codes plus a `categories` attribute (legacy 0.7.x categorical),
|
|
9
|
+
* a group of `values` + `mask` (`nullable-string-array`, what anndata >= 0.11
|
|
10
|
+
writes for every string column *including the dataframe index*).
|
|
11
|
+
|
|
12
|
+
The last case is why this module exists: code that assumed an index was always
|
|
13
|
+
a dataset fails outright on any recent file.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from adata.elements import spec
|
|
23
|
+
from adata.storage import is_dataset, is_group, is_hdf5_dataset
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def decode_str_array(array: np.ndarray) -> np.ndarray:
|
|
27
|
+
"""Decode an array of bytes/objects to a numpy array of str."""
|
|
28
|
+
arr = np.asarray(array)
|
|
29
|
+
|
|
30
|
+
if np.issubdtype(arr.dtype, np.bytes_):
|
|
31
|
+
flat = arr.reshape(-1)
|
|
32
|
+
decoded = [
|
|
33
|
+
(
|
|
34
|
+
b.decode("utf-8", errors="replace")
|
|
35
|
+
if isinstance(b, (bytes, np.bytes_))
|
|
36
|
+
else str(b)
|
|
37
|
+
)
|
|
38
|
+
for b in flat
|
|
39
|
+
]
|
|
40
|
+
return np.asarray(decoded, dtype=str).reshape(arr.shape)
|
|
41
|
+
|
|
42
|
+
# "T" is numpy's StringDType, which zarr-python 3 reports for both
|
|
43
|
+
# VariableLengthUTF8 (v3) and VLenUTF8 (v2); it refuses a direct astype(str).
|
|
44
|
+
if arr.dtype.kind in ("O", "T"):
|
|
45
|
+
flat = arr.reshape(-1)
|
|
46
|
+
decoded = [
|
|
47
|
+
(
|
|
48
|
+
v.decode("utf-8", errors="replace")
|
|
49
|
+
if isinstance(v, (bytes, np.bytes_))
|
|
50
|
+
else str(v)
|
|
51
|
+
)
|
|
52
|
+
for v in flat
|
|
53
|
+
]
|
|
54
|
+
return np.asarray(decoded, dtype=str).reshape(arr.shape)
|
|
55
|
+
|
|
56
|
+
return arr.astype(str)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def element_len(obj: Any) -> int:
|
|
60
|
+
"""Length along the first axis, whether `obj` is a dataset or a group.
|
|
61
|
+
|
|
62
|
+
Handles the masked (`values`/`mask`) and categorical group layouts, so
|
|
63
|
+
callers never need to know how a column happens to be stored.
|
|
64
|
+
"""
|
|
65
|
+
if is_dataset(obj):
|
|
66
|
+
shape = getattr(obj, "shape", None)
|
|
67
|
+
if not shape:
|
|
68
|
+
raise ValueError("Element is a scalar and has no length.")
|
|
69
|
+
return int(shape[0])
|
|
70
|
+
|
|
71
|
+
if is_group(obj):
|
|
72
|
+
enc = spec.encoding_type(obj)
|
|
73
|
+
if enc in spec.MASKED_TYPES or "values" in obj:
|
|
74
|
+
return int(obj["values"].shape[0])
|
|
75
|
+
if enc == spec.CATEGORICAL or "codes" in obj:
|
|
76
|
+
return int(obj["codes"].shape[0])
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"Cannot determine length of group with encoding {enc!r}: "
|
|
79
|
+
"expected 'values' or 'codes'."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
raise TypeError(f"Unsupported element type: {type(obj)}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_index(group: Any, axis: Optional[str] = None) -> Tuple[Any, str]:
|
|
86
|
+
"""Return the ``(index_element, index_name)`` of a dataframe group.
|
|
87
|
+
|
|
88
|
+
Honours the declared ``_index`` attribute first and only then falls back to
|
|
89
|
+
the ``obs_names``/``var_names`` convention -- the reverse of that order
|
|
90
|
+
picks the wrong column on a file carrying both.
|
|
91
|
+
"""
|
|
92
|
+
index_name = spec.decode_attr(group.attrs.get("_index", None))
|
|
93
|
+
|
|
94
|
+
candidates: List[str] = []
|
|
95
|
+
if index_name:
|
|
96
|
+
candidates.append(str(index_name))
|
|
97
|
+
if axis == "obs":
|
|
98
|
+
candidates.append("obs_names")
|
|
99
|
+
elif axis == "var":
|
|
100
|
+
candidates.append("var_names")
|
|
101
|
+
else:
|
|
102
|
+
candidates.extend(("obs_names", "var_names"))
|
|
103
|
+
candidates.append("_index")
|
|
104
|
+
|
|
105
|
+
for name in candidates:
|
|
106
|
+
if name in group:
|
|
107
|
+
return group[name], name
|
|
108
|
+
|
|
109
|
+
raise KeyError(
|
|
110
|
+
f"Could not find an index in group {getattr(group, 'name', '?')!r}; "
|
|
111
|
+
f"tried {candidates}."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def dataframe_columns(group: Any, index_name: Optional[str] = None) -> List[str]:
|
|
116
|
+
"""Return a dataframe group's data columns in their authored order.
|
|
117
|
+
|
|
118
|
+
The spec records original column order in the `column-order` attribute;
|
|
119
|
+
without it the order would be whatever the backend enumerates, which is
|
|
120
|
+
alphabetical on HDF5. Columns present on disk but absent from the
|
|
121
|
+
attribute are appended so nothing is ever dropped.
|
|
122
|
+
"""
|
|
123
|
+
if index_name is None:
|
|
124
|
+
try:
|
|
125
|
+
_, index_name = resolve_index(group)
|
|
126
|
+
except KeyError:
|
|
127
|
+
index_name = None
|
|
128
|
+
|
|
129
|
+
skip = set(spec.RESERVED_DATAFRAME_KEYS)
|
|
130
|
+
if index_name:
|
|
131
|
+
skip.add(index_name)
|
|
132
|
+
|
|
133
|
+
present = [k for k in group.keys() if k not in skip]
|
|
134
|
+
|
|
135
|
+
declared = group.attrs.get("column-order", None)
|
|
136
|
+
if declared is None:
|
|
137
|
+
return present
|
|
138
|
+
|
|
139
|
+
ordered = [
|
|
140
|
+
str(spec.decode_attr(c))
|
|
141
|
+
for c in np.asarray(declared).reshape(-1).tolist()
|
|
142
|
+
]
|
|
143
|
+
seen = set()
|
|
144
|
+
out = [c for c in ordered if c in present and not (c in seen or seen.add(c))]
|
|
145
|
+
out.extend(c for c in present if c not in seen)
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _categorical_cache_key(col: Any, parent_group: Any | None = None) -> str:
|
|
150
|
+
col_name = getattr(col, "name", None)
|
|
151
|
+
if isinstance(col_name, str) and col_name:
|
|
152
|
+
return col_name
|
|
153
|
+
|
|
154
|
+
if parent_group is not None:
|
|
155
|
+
parent_name = getattr(parent_group, "name", "")
|
|
156
|
+
rel_name = getattr(col, "path", "")
|
|
157
|
+
if parent_name or rel_name:
|
|
158
|
+
return f"{parent_name}/{rel_name}"
|
|
159
|
+
|
|
160
|
+
return repr(col)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_categories(col: Any, parent_group: Any | None = None) -> np.ndarray:
|
|
164
|
+
"""Read the category labels of a categorical column, modern or legacy."""
|
|
165
|
+
if is_group(col):
|
|
166
|
+
return np.asarray(decode_str_array(col["categories"][...]), dtype=str)
|
|
167
|
+
|
|
168
|
+
cats_ref = col.attrs.get("categories", None)
|
|
169
|
+
if cats_ref is not None and is_hdf5_dataset(col):
|
|
170
|
+
# Legacy 0.7.x HDF5: an object reference into __categories.
|
|
171
|
+
return np.asarray(decode_str_array(col.file[cats_ref][...]), dtype=str)
|
|
172
|
+
|
|
173
|
+
if parent_group is not None and "__categories" in parent_group:
|
|
174
|
+
col_name = str(getattr(col, "name", "")).split("/")[-1]
|
|
175
|
+
cats_grp = parent_group["__categories"]
|
|
176
|
+
if col_name in cats_grp:
|
|
177
|
+
return np.asarray(decode_str_array(cats_grp[col_name][...]), dtype=str)
|
|
178
|
+
|
|
179
|
+
if cats_ref is not None and isinstance(cats_ref, (str, bytes)):
|
|
180
|
+
# Legacy Zarr: `categories` is an absolute path within the store.
|
|
181
|
+
path = spec.decode_attr(cats_ref).lstrip("/")
|
|
182
|
+
root = getattr(col, "store_path", None)
|
|
183
|
+
if parent_group is not None and path.split("/")[-1] in parent_group:
|
|
184
|
+
return np.asarray(
|
|
185
|
+
decode_str_array(parent_group[path.split("/")[-1]][...]), dtype=str
|
|
186
|
+
)
|
|
187
|
+
del root
|
|
188
|
+
|
|
189
|
+
raise KeyError(
|
|
190
|
+
f"Cannot find categories for categorical column "
|
|
191
|
+
f"{getattr(col, 'name', '?')!r}."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def is_ordered(col: Any) -> bool:
|
|
196
|
+
"""Whether a categorical column declares an order over its categories."""
|
|
197
|
+
return bool(spec.decode_attr(col.attrs.get("ordered", False)))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def read_categorical_column(
|
|
201
|
+
col: Any,
|
|
202
|
+
start: int,
|
|
203
|
+
end: int,
|
|
204
|
+
cache: Dict[str, np.ndarray],
|
|
205
|
+
parent_group: Any | None = None,
|
|
206
|
+
) -> List[str]:
|
|
207
|
+
"""Read rows [start, end) of a categorical column as strings.
|
|
208
|
+
|
|
209
|
+
Codes outside the category range -- notably -1 -- render as empty, which is
|
|
210
|
+
how the spec denotes a missing value.
|
|
211
|
+
"""
|
|
212
|
+
key = _categorical_cache_key(col, parent_group)
|
|
213
|
+
if key not in cache:
|
|
214
|
+
cache[key] = read_categories(col, parent_group)
|
|
215
|
+
cats = cache[key]
|
|
216
|
+
|
|
217
|
+
codes_ds = col["codes"] if is_group(col) else col
|
|
218
|
+
codes = np.asarray(codes_ds[start:end], dtype=np.int64)
|
|
219
|
+
return [cats[c] if 0 <= c < len(cats) else "" for c in codes]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _format_values(values: np.ndarray) -> List[str]:
|
|
223
|
+
"""Render a numeric or text array as strings without a lossy detour."""
|
|
224
|
+
arr = np.asarray(values)
|
|
225
|
+
if arr.dtype.kind in ("O", "S", "U", "T"):
|
|
226
|
+
return decode_str_array(arr).tolist()
|
|
227
|
+
return [str(v) for v in arr.tolist()]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def read_masked_column(
|
|
231
|
+
col: Any, start: int, end: int, na_repr: str = ""
|
|
232
|
+
) -> List[str]:
|
|
233
|
+
"""Read rows [start, end) of a `values`/`mask` group as strings.
|
|
234
|
+
|
|
235
|
+
A True mask marks a missing entry, rendered as `na_repr`.
|
|
236
|
+
"""
|
|
237
|
+
values = col["values"][start:end]
|
|
238
|
+
mask = np.asarray(col["mask"][start:end], dtype=bool)
|
|
239
|
+
rendered = _format_values(values)
|
|
240
|
+
return [na_repr if m else v for v, m in zip(rendered, mask)]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def read_str_chunk(
|
|
244
|
+
obj: Any,
|
|
245
|
+
start: int,
|
|
246
|
+
end: int,
|
|
247
|
+
cache: Optional[Dict[str, np.ndarray]] = None,
|
|
248
|
+
parent_group: Any | None = None,
|
|
249
|
+
na_repr: str = "",
|
|
250
|
+
) -> List[str]:
|
|
251
|
+
"""Read rows [start, end) of any column-like element as strings.
|
|
252
|
+
|
|
253
|
+
This is the one entry point for turning a column into text, whatever
|
|
254
|
+
layout it uses on disk.
|
|
255
|
+
"""
|
|
256
|
+
if cache is None:
|
|
257
|
+
cache = {}
|
|
258
|
+
|
|
259
|
+
if is_dataset(obj):
|
|
260
|
+
if "categories" in obj.attrs:
|
|
261
|
+
return read_categorical_column(obj, start, end, cache, parent_group)
|
|
262
|
+
chunk = obj[start:end]
|
|
263
|
+
arr = np.asarray(chunk)
|
|
264
|
+
if arr.ndim != 1:
|
|
265
|
+
arr = arr.reshape(-1)
|
|
266
|
+
return _format_values(arr)
|
|
267
|
+
|
|
268
|
+
if is_group(obj):
|
|
269
|
+
enc = spec.encoding_type(obj)
|
|
270
|
+
|
|
271
|
+
if enc == spec.CATEGORICAL or (enc is None and "codes" in obj):
|
|
272
|
+
return read_categorical_column(obj, start, end, cache, parent_group)
|
|
273
|
+
|
|
274
|
+
if enc in spec.MASKED_TYPES or (enc is None and "values" in obj and "mask" in obj):
|
|
275
|
+
return read_masked_column(obj, start, end, na_repr=na_repr)
|
|
276
|
+
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"Unsupported group encoding {enc!r} for element "
|
|
279
|
+
f"{getattr(obj, 'name', '?')!r}."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
raise TypeError(f"Unsupported element type: {type(obj)}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def read_str_all(obj: Any, chunk_size: int = 200_000, **kwargs: Any) -> List[str]:
|
|
286
|
+
"""Read an entire column-like element as strings, in chunks."""
|
|
287
|
+
n = element_len(obj)
|
|
288
|
+
cache: Dict[str, np.ndarray] = {}
|
|
289
|
+
out: List[str] = []
|
|
290
|
+
for start in range(0, n, chunk_size):
|
|
291
|
+
out.extend(
|
|
292
|
+
read_str_chunk(obj, start, min(start + chunk_size, n), cache, **kwargs)
|
|
293
|
+
)
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def col_chunk_as_strings(
|
|
298
|
+
group: Any,
|
|
299
|
+
col_name: str,
|
|
300
|
+
start: int,
|
|
301
|
+
end: int,
|
|
302
|
+
cat_cache: Dict[str, np.ndarray],
|
|
303
|
+
) -> List[str]:
|
|
304
|
+
"""Read rows [start, end) of a named column within a dataframe group."""
|
|
305
|
+
if col_name not in group:
|
|
306
|
+
raise RuntimeError(
|
|
307
|
+
f"Column {col_name!r} not found in group "
|
|
308
|
+
f"{getattr(group, 'name', '?')!r}"
|
|
309
|
+
)
|
|
310
|
+
return read_str_chunk(group[col_name], start, end, cat_cache, parent_group=group)
|
adata/elements/spec.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""AnnData on-disk encoding constants and attribute access.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for `encoding-type` / `encoding-version`
|
|
4
|
+
pairs. Every read that inspects an encoding and every write that stamps one
|
|
5
|
+
goes through here, so the spec version the tool targets is stated in exactly
|
|
6
|
+
one place.
|
|
7
|
+
|
|
8
|
+
See docs/ELEMENTS_h5ad.md and docs/ELEMENTS_zarr.md for the full spec.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
ANNDATA = "anndata"
|
|
16
|
+
RAW = "raw"
|
|
17
|
+
DICT = "dict"
|
|
18
|
+
DATAFRAME = "dataframe"
|
|
19
|
+
ARRAY = "array"
|
|
20
|
+
STRING_ARRAY = "string-array"
|
|
21
|
+
CATEGORICAL = "categorical"
|
|
22
|
+
CSR_MATRIX = "csr_matrix"
|
|
23
|
+
CSC_MATRIX = "csc_matrix"
|
|
24
|
+
NULLABLE_INTEGER = "nullable-integer"
|
|
25
|
+
NULLABLE_BOOLEAN = "nullable-boolean"
|
|
26
|
+
NULLABLE_STRING_ARRAY = "nullable-string-array"
|
|
27
|
+
NUMERIC_SCALAR = "numeric-scalar"
|
|
28
|
+
STRING = "string"
|
|
29
|
+
AWKWARD_ARRAY = "awkward-array"
|
|
30
|
+
NULL = "null"
|
|
31
|
+
|
|
32
|
+
#: Encoding type -> the version this tool writes.
|
|
33
|
+
CURRENT_VERSION = {
|
|
34
|
+
ANNDATA: "0.1.0",
|
|
35
|
+
RAW: "0.1.0",
|
|
36
|
+
DICT: "0.1.0",
|
|
37
|
+
DATAFRAME: "0.2.0",
|
|
38
|
+
ARRAY: "0.2.0",
|
|
39
|
+
STRING_ARRAY: "0.2.0",
|
|
40
|
+
CATEGORICAL: "0.2.0",
|
|
41
|
+
CSR_MATRIX: "0.1.0",
|
|
42
|
+
CSC_MATRIX: "0.1.0",
|
|
43
|
+
NULLABLE_INTEGER: "0.1.0",
|
|
44
|
+
NULLABLE_BOOLEAN: "0.1.0",
|
|
45
|
+
NULLABLE_STRING_ARRAY: "0.1.0",
|
|
46
|
+
NUMERIC_SCALAR: "0.2.0",
|
|
47
|
+
STRING: "0.2.0",
|
|
48
|
+
AWKWARD_ARRAY: "0.1.0",
|
|
49
|
+
NULL: "0.1.0",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
SPARSE_TYPES = frozenset({CSR_MATRIX, CSC_MATRIX})
|
|
53
|
+
NULLABLE_TYPES = frozenset(
|
|
54
|
+
{NULLABLE_INTEGER, NULLABLE_BOOLEAN, NULLABLE_STRING_ARRAY}
|
|
55
|
+
)
|
|
56
|
+
#: Encodings stored as a group holding `values` and `mask`.
|
|
57
|
+
MASKED_TYPES = NULLABLE_TYPES
|
|
58
|
+
#: Encodings that yield text when read as strings.
|
|
59
|
+
STRING_TYPES = frozenset({STRING_ARRAY, NULLABLE_STRING_ARRAY, STRING, CATEGORICAL})
|
|
60
|
+
#: Group encodings that are plain mappings of further elements.
|
|
61
|
+
MAPPING_TYPES = frozenset({DICT, ANNDATA, RAW})
|
|
62
|
+
|
|
63
|
+
#: Keys that are structural rather than dataframe columns.
|
|
64
|
+
RESERVED_DATAFRAME_KEYS = frozenset({"_index", "__categories"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decode_attr(value: Any) -> Any:
|
|
68
|
+
"""Decode a raw attribute value to a plain Python value.
|
|
69
|
+
|
|
70
|
+
HDF5 hands back `bytes` for string attributes while Zarr hands back `str`;
|
|
71
|
+
numpy scalars appear on both. Normalising here is what lets the rest of the
|
|
72
|
+
codebase compare attributes with `==`.
|
|
73
|
+
"""
|
|
74
|
+
if isinstance(value, bytes):
|
|
75
|
+
return value.decode("utf-8")
|
|
76
|
+
if hasattr(value, "item") and getattr(value, "shape", None) == ():
|
|
77
|
+
return value.item()
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def encoding_of(obj: Any) -> Tuple[Optional[str], Optional[str]]:
|
|
82
|
+
"""Return the declared ``(encoding-type, encoding-version)`` of an element.
|
|
83
|
+
|
|
84
|
+
Either component is None when the element is untagged, which is the normal
|
|
85
|
+
case for files written by anndata 0.7.x and earlier.
|
|
86
|
+
"""
|
|
87
|
+
attrs = getattr(obj, "attrs", {})
|
|
88
|
+
enc = decode_attr(attrs.get("encoding-type", None))
|
|
89
|
+
ver = decode_attr(attrs.get("encoding-version", None))
|
|
90
|
+
return (enc or None, ver or None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def encoding_type(obj: Any) -> Optional[str]:
|
|
94
|
+
"""Return just the declared ``encoding-type``, or None if untagged."""
|
|
95
|
+
return encoding_of(obj)[0]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def set_attrs(obj: Any, values: Dict[str, Any]) -> None:
|
|
99
|
+
"""Set several attributes in as few writes as the backend allows.
|
|
100
|
+
|
|
101
|
+
On Zarr each assignment persists the whole metadata document through its
|
|
102
|
+
sync-over-async bridge, so setting attributes one at a time costs a round
|
|
103
|
+
trip each. HDF5 has no such batch API and falls back to assignment.
|
|
104
|
+
"""
|
|
105
|
+
if not values:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
put = getattr(obj.attrs, "put", None)
|
|
109
|
+
if callable(put):
|
|
110
|
+
put({**dict(obj.attrs), **values})
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
for key, value in values.items():
|
|
114
|
+
obj.attrs[key] = value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def set_encoding(obj: Any, enc_type: str, version: Optional[str] = None) -> None:
|
|
118
|
+
"""Stamp ``encoding-type``/``encoding-version`` on a group or dataset.
|
|
119
|
+
|
|
120
|
+
The version defaults to the current spec version for that type, so callers
|
|
121
|
+
never hardcode one.
|
|
122
|
+
"""
|
|
123
|
+
if version is None:
|
|
124
|
+
version = CURRENT_VERSION[enc_type]
|
|
125
|
+
set_attrs(obj, {"encoding-type": enc_type, "encoding-version": version})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Backend-neutral handling of string elements.
|
|
2
|
+
|
|
3
|
+
The AnnData spec requires `string-array` to be variable-length UTF-8: a vlen
|
|
4
|
+
str dtype in HDF5, `VariableLengthUTF8` in Zarr. Neither backend accepts the
|
|
5
|
+
other's spelling, and a dtype round-tripped verbatim across backends fails
|
|
6
|
+
outright -- an h5py vlen dataset reports `dtype == object`, which Zarr rejects
|
|
7
|
+
as ambiguous. Everything that creates or copies a string element therefore
|
|
8
|
+
resolves the dtype through this module rather than reusing the source's.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import h5py
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from adata.storage import is_dataset, is_zarr_array
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_string_dtype(dtype: Any) -> bool:
|
|
22
|
+
"""True if `dtype` holds text, in any spelling either backend produces.
|
|
23
|
+
|
|
24
|
+
Covers h5py vlen-UTF-8 (object), fixed-width bytes ('S'), numpy unicode
|
|
25
|
+
('U'), numpy StringDType ('T', what zarr-python 3 reports for both
|
|
26
|
+
`VariableLengthUTF8` and v2 `VLenUTF8`), and bare object arrays.
|
|
27
|
+
"""
|
|
28
|
+
if dtype is None:
|
|
29
|
+
return False
|
|
30
|
+
if dtype is str:
|
|
31
|
+
return True
|
|
32
|
+
try:
|
|
33
|
+
if h5py.check_string_dtype(dtype) is not None:
|
|
34
|
+
return True
|
|
35
|
+
except (TypeError, AttributeError):
|
|
36
|
+
pass
|
|
37
|
+
kind = getattr(dtype, "kind", None)
|
|
38
|
+
return kind in ("O", "S", "U", "T")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_string_element(obj: Any) -> bool:
|
|
42
|
+
"""True if `obj` is a dataset/array holding text."""
|
|
43
|
+
return is_dataset(obj) and is_string_dtype(getattr(obj, "dtype", None))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def string_dtype_for(backend: str, zarr_format: Optional[int] = None) -> Any:
|
|
47
|
+
"""Return the spec-compliant variable-length UTF-8 dtype for `backend`.
|
|
48
|
+
|
|
49
|
+
`zarr_format` is accepted for symmetry; zarr-python 3 maps `str` to
|
|
50
|
+
`VariableLengthUTF8` under both v2 and v3, so it does not currently affect
|
|
51
|
+
the result.
|
|
52
|
+
"""
|
|
53
|
+
if backend == "zarr":
|
|
54
|
+
return str
|
|
55
|
+
return h5py.string_dtype(encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def target_dtype(src_dtype: Any, target_backend: str, zarr_format: Optional[int] = None) -> Any:
|
|
59
|
+
"""Map a source dtype onto one the target backend can actually create.
|
|
60
|
+
|
|
61
|
+
String dtypes are normalised to variable-length UTF-8; everything else is
|
|
62
|
+
passed through unchanged.
|
|
63
|
+
"""
|
|
64
|
+
if is_string_dtype(src_dtype):
|
|
65
|
+
return string_dtype_for(target_backend, zarr_format)
|
|
66
|
+
return src_dtype
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def as_str_array(values: Any) -> np.ndarray:
|
|
70
|
+
"""Coerce a sequence to a 1-D numpy array of Python str, decoding bytes."""
|
|
71
|
+
arr = np.asarray(values, dtype=object)
|
|
72
|
+
flat = [
|
|
73
|
+
v.decode("utf-8", errors="replace") if isinstance(v, (bytes, np.bytes_)) else str(v)
|
|
74
|
+
for v in arr.reshape(-1)
|
|
75
|
+
]
|
|
76
|
+
return np.asarray(flat, dtype=object).reshape(arr.shape)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def strip_string_filters(kwargs: dict, target_backend: str, zarr_format: Optional[int]) -> dict:
|
|
80
|
+
"""Drop codecs that cannot survive the jump to `target_backend`.
|
|
81
|
+
|
|
82
|
+
A Zarr v2 string array carries `VLenUTF8` in `filters`; forwarding it to a
|
|
83
|
+
v3 array raises `TypeError: Expected an ArrayArrayCodec`. The v3 string
|
|
84
|
+
dtype encodes variable length itself, so the filter is simply dropped.
|
|
85
|
+
"""
|
|
86
|
+
if target_backend != "zarr" or zarr_format != 3:
|
|
87
|
+
return kwargs
|
|
88
|
+
out = dict(kwargs)
|
|
89
|
+
out.pop("filters", None)
|
|
90
|
+
return out
|