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/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]
|