pyadata-cli 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adata/core/concat.py ADDED
@@ -0,0 +1,1084 @@
1
+ """Concatenating AnnData stores on disk, along the obs axis.
2
+
3
+ Mirrors the useful core of `anndata.experimental.concat_on_disk`: inputs are
4
+ streamed a block of rows at a time and written straight into the output, so
5
+ peak memory is set by the block size rather than by the total size of the
6
+ inputs.
7
+
8
+ Columns of the concatenated axis follow `join`; elements of the alternative
9
+ axis and of uns follow a `merge` strategy, since there is no single correct
10
+ way to reconcile values that differ between inputs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
17
+
18
+ import numpy as np
19
+ from rich.console import Console
20
+
21
+ from adata.core.info import axis_len
22
+ from adata.core.select import read_names
23
+ from adata.elements import spec
24
+ from adata.elements.read import (
25
+ dataframe_columns,
26
+ element_len,
27
+ read_str_all,
28
+ read_categories,
29
+ is_ordered,
30
+ resolve_index,
31
+ )
32
+ from adata.elements.write import (
33
+ ensure_anndata_skeleton,
34
+ set_column_order,
35
+ write_categorical,
36
+ write_dataframe_header,
37
+ write_dense,
38
+ write_masked,
39
+ write_mapping,
40
+ write_string_array,
41
+ )
42
+ from adata.storage import (
43
+ copy_tree,
44
+ create_dataset,
45
+ is_dataset,
46
+ is_group,
47
+ open_store,
48
+ )
49
+
50
+ MERGE_STRATEGIES = ("same", "unique", "first", "only")
51
+
52
+
53
+ def _index_union(per_input: Sequence[List[str]]) -> List[str]:
54
+ """Union of indices, in order of first appearance."""
55
+ seen: Dict[str, None] = {}
56
+ for names in per_input:
57
+ for name in names:
58
+ seen.setdefault(name, None)
59
+ return list(seen)
60
+
61
+
62
+ def _index_intersection(per_input: Sequence[List[str]]) -> List[str]:
63
+ """Intersection of indices, ordered by the first input."""
64
+ common = set(per_input[0])
65
+ for names in per_input[1:]:
66
+ common &= set(names)
67
+ return [name for name in per_input[0] if name in common]
68
+
69
+
70
+ def _column_map(source: List[str], target: List[str]) -> np.ndarray:
71
+ """Map each source position to its target position, or -1 if dropped."""
72
+ lookup = {name: i for i, name in enumerate(target)}
73
+ return np.fromiter(
74
+ (lookup.get(name, -1) for name in source), dtype=np.int64, count=len(source)
75
+ )
76
+
77
+
78
+ def _apply_index_unique(
79
+ names: List[str], key: Optional[str], delimiter: Optional[str]
80
+ ) -> List[str]:
81
+ if delimiter is None or key is None:
82
+ return names
83
+ return [f"{name}{delimiter}{key}" for name in names]
84
+
85
+
86
+ def _resolve_keys(
87
+ files: Sequence[Path], keys: Optional[Sequence[str]]
88
+ ) -> List[str]:
89
+ """Name each input, defaulting to its filename stem.
90
+
91
+ Keys must be distinct: `--label` writes them as categorical categories,
92
+ which have to be unique, and `--index-unique` uses them to disambiguate obs
93
+ names. Duplicates would otherwise produce an output that reports success
94
+ but cannot be read back.
95
+ """
96
+ if keys is None:
97
+ resolved = [f.stem if f.suffix else f.name for f in files]
98
+ duplicates = _duplicates(resolved)
99
+ if duplicates:
100
+ raise ValueError(
101
+ f"Inputs in different directories share the filename(s) "
102
+ f"{', '.join(duplicates)}, so the default keys are not unique. "
103
+ "Pass --keys to name them explicitly."
104
+ )
105
+ return resolved
106
+
107
+ if len(keys) != len(files):
108
+ raise ValueError(
109
+ f"--keys has {len(keys)} entries but {len(files)} inputs were given."
110
+ )
111
+ duplicates = _duplicates(keys)
112
+ if duplicates:
113
+ raise ValueError(
114
+ f"--keys must be unique; repeated: {', '.join(duplicates)}."
115
+ )
116
+ return list(keys)
117
+
118
+
119
+ def _duplicates(values: Sequence[str]) -> List[str]:
120
+ seen: Dict[str, int] = {}
121
+ for value in values:
122
+ seen[value] = seen.get(value, 0) + 1
123
+ return sorted(v for v, n in seen.items() if n > 1)
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # merge strategies
128
+
129
+
130
+ def _merge_values(values: List[Any], strategy: Optional[str]) -> Tuple[bool, Any]:
131
+ """Reconcile one element's value across inputs.
132
+
133
+ Returns ``(keep, value)``. The strategies match anndata's: "same" keeps a
134
+ value only when every input agrees, "unique" when there is exactly one
135
+ distinct value among those present, "first" takes the earliest present,
136
+ and "only" keeps it when exactly one input has it at all.
137
+ """
138
+ if strategy is None:
139
+ return False, None
140
+
141
+ present = [v for v in values if v is not _MISSING]
142
+ if not present:
143
+ return False, None
144
+
145
+ if strategy == "first":
146
+ return True, present[0]
147
+
148
+ if strategy == "only":
149
+ return (len(present) == 1), (present[0] if len(present) == 1 else None)
150
+
151
+ distinct: List[Any] = []
152
+ for value in present:
153
+ if not any(_equal(value, seen) for seen in distinct):
154
+ distinct.append(value)
155
+
156
+ if strategy == "unique":
157
+ return (len(distinct) == 1), (distinct[0] if len(distinct) == 1 else None)
158
+
159
+ if strategy == "same":
160
+ keep = len(distinct) == 1 and len(present) == len(values)
161
+ return keep, (distinct[0] if keep else None)
162
+
163
+ raise ValueError(
164
+ f"Unknown merge strategy {strategy!r}. "
165
+ f"Choose from: {', '.join(MERGE_STRATEGIES)}"
166
+ )
167
+
168
+
169
+ class _Missing:
170
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
171
+ return "<missing>"
172
+
173
+
174
+ _MISSING = _Missing()
175
+
176
+
177
+ def _equal(a: Any, b: Any) -> bool:
178
+ if isinstance(a, _Incomparable) or isinstance(b, _Incomparable):
179
+ return False
180
+ try:
181
+ if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
182
+ return np.array_equal(np.asarray(a), np.asarray(b))
183
+ return bool(a == b)
184
+ except Exception:
185
+ return False
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # obs columns
190
+
191
+
192
+ def _column_kind(obj: Any) -> str:
193
+ """Classify a column by how it must be concatenated."""
194
+ enc = spec.encoding_type(obj)
195
+ if enc == spec.CATEGORICAL or (is_group(obj) and "codes" in obj):
196
+ return "categorical"
197
+ if enc in spec.MASKED_TYPES or (is_group(obj) and "values" in obj):
198
+ return "masked"
199
+ if is_dataset(obj):
200
+ from adata.elements.strings import is_string_dtype
201
+
202
+ return "string" if is_string_dtype(obj.dtype) else "numeric"
203
+ return "other"
204
+
205
+
206
+ def _concat_categorical(
207
+ parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
208
+ ) -> None:
209
+ """Concatenate categorical columns, unioning their category sets.
210
+
211
+ Inputs rarely share a category order, so codes are remapped rather than
212
+ concatenated directly. A row from an input lacking the column gets code
213
+ -1, which anndata reads back as a missing value.
214
+ """
215
+ categories: List[str] = []
216
+ for col in columns:
217
+ if col is None:
218
+ continue
219
+ for category in read_categories(col):
220
+ if category not in categories:
221
+ categories.append(str(category))
222
+
223
+ lookup = {c: i for i, c in enumerate(categories)}
224
+ ordered = all(is_ordered(c) for c in columns if c is not None)
225
+
226
+ codes = np.full(sum(lengths), -1, dtype=np.int64)
227
+ offset = 0
228
+ for col, length in zip(columns, lengths):
229
+ if col is not None:
230
+ source_cats = [str(c) for c in read_categories(col)]
231
+ remap = np.array(
232
+ [lookup[c] for c in source_cats] + [-1], dtype=np.int64
233
+ )
234
+ source_codes = np.asarray(col["codes"][...], dtype=np.int64)
235
+ source_codes[source_codes < 0] = len(source_cats)
236
+ codes[offset : offset + length] = remap[source_codes]
237
+ offset += length
238
+
239
+ write_categorical(parent, name, codes, categories, ordered=ordered)
240
+
241
+
242
+ def _concat_numeric(
243
+ parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
244
+ ) -> None:
245
+ """Concatenate numeric columns, promoting to float when rows are missing.
246
+
247
+ An integer column cannot represent "absent", so a column missing from some
248
+ input is promoted to float and padded with NaN -- the same thing pandas
249
+ does on an outer join.
250
+ """
251
+ present = [c for c in columns if c is not None]
252
+ dtype = np.result_type(*[c.dtype for c in present])
253
+ complete = len(present) == len(columns)
254
+
255
+ if not complete and dtype.kind in ("i", "u", "b"):
256
+ dtype = np.dtype("float64")
257
+
258
+ out = np.empty(sum(lengths), dtype=dtype)
259
+ if not complete:
260
+ out[:] = np.nan if dtype.kind == "f" else 0
261
+
262
+ offset = 0
263
+ for col, length in zip(columns, lengths):
264
+ if col is not None:
265
+ out[offset : offset + length] = np.asarray(col[...], dtype=dtype)
266
+ offset += length
267
+
268
+ write_dense(parent, name, out)
269
+
270
+
271
+ def _concat_masked(
272
+ parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
273
+ ) -> None:
274
+ """Concatenate nullable columns, masking rows from inputs that lack them."""
275
+ kinds = {spec.encoding_type(c) for c in columns if c is not None}
276
+ enc = kinds.pop() if len(kinds) == 1 else spec.NULLABLE_STRING_ARRAY
277
+
278
+ total = sum(lengths)
279
+ mask = np.ones(total, dtype=bool)
280
+ values: List[Any] = [None] * total
281
+
282
+ offset = 0
283
+ for col, length in zip(columns, lengths):
284
+ if col is not None:
285
+ chunk = np.asarray(col["values"][...])
286
+ chunk_mask = np.asarray(col["mask"][...], dtype=bool)
287
+ for i in range(length):
288
+ values[offset + i] = chunk[i]
289
+ mask[offset : offset + length] = chunk_mask
290
+ offset += length
291
+
292
+ if enc == spec.NULLABLE_STRING_ARRAY:
293
+ from adata.elements.read import decode_str_array
294
+
295
+ filled = [
296
+ "" if v is None else decode_str_array(np.asarray([v]))[0] for v in values
297
+ ]
298
+ write_masked(parent, name, filled, mask, enc)
299
+ return
300
+
301
+ present = [c for c in columns if c is not None]
302
+ dtype = np.result_type(*[c["values"].dtype for c in present])
303
+ filled_num = np.array(
304
+ [0 if v is None else v for v in values], dtype=dtype
305
+ )
306
+ write_masked(parent, name, filled_num, mask, enc)
307
+
308
+
309
+ def _concat_string(
310
+ parent: Any, name: str, columns: List[Optional[Any]], lengths: List[int]
311
+ ) -> None:
312
+ """Concatenate string columns, masking rows from inputs that lack them."""
313
+ total = sum(lengths)
314
+ values: List[str] = [""] * total
315
+ mask = np.zeros(total, dtype=bool)
316
+
317
+ offset = 0
318
+ for col, length in zip(columns, lengths):
319
+ if col is None:
320
+ mask[offset : offset + length] = True
321
+ else:
322
+ values[offset : offset + length] = read_str_all(col)
323
+ offset += length
324
+
325
+ if mask.any():
326
+ write_masked(parent, name, values, mask, spec.NULLABLE_STRING_ARRAY)
327
+ else:
328
+ write_string_array(parent, name, values)
329
+
330
+
331
+ def _concat_obs_column(
332
+ parent: Any,
333
+ name: str,
334
+ columns: List[Optional[Any]],
335
+ lengths: List[int],
336
+ console: Console,
337
+ ) -> bool:
338
+ """Write one concatenated annotation column. Returns whether it was kept."""
339
+ kinds = {_column_kind(c) for c in columns if c is not None}
340
+
341
+ if kinds == {"categorical"}:
342
+ _concat_categorical(parent, name, columns, lengths)
343
+ elif kinds == {"numeric"}:
344
+ _concat_numeric(parent, name, columns, lengths)
345
+ elif kinds == {"masked"}:
346
+ _concat_masked(parent, name, columns, lengths)
347
+ elif kinds <= {"string", "categorical", "masked"}:
348
+ # Mixed text-like encodings: fall back to plain strings, which every
349
+ # one of them can represent.
350
+ _concat_string(parent, name, columns, lengths)
351
+ else:
352
+ console.print(
353
+ f"[yellow]Dropping column {name!r}: "
354
+ f"cannot concatenate encodings {sorted(kinds)}[/]"
355
+ )
356
+ return False
357
+ return True
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # matrices
362
+
363
+
364
+ def _matrix_kind(obj: Any) -> str:
365
+ enc = spec.encoding_type(obj)
366
+ if enc in spec.SPARSE_TYPES:
367
+ return enc
368
+ if is_dataset(obj):
369
+ return "dense"
370
+ return "other"
371
+
372
+
373
+ def _concat_sparse(
374
+ dst_parent: Any,
375
+ name: str,
376
+ sources: List[Any],
377
+ col_maps: List[np.ndarray],
378
+ n_rows: int,
379
+ n_cols: int,
380
+ chunk_rows: int,
381
+ ) -> None:
382
+ """Concatenate CSR matrices row-wise, remapping columns as they stream.
383
+
384
+ Absent columns need no fill: a sparse matrix's zeros are implicit, so
385
+ dropped entries simply do not appear in the output.
386
+ """
387
+ from adata.core.subset import _append, _growable
388
+
389
+ group = dst_parent.create_group(name)
390
+ spec.set_encoding(group, spec.CSR_MATRIX)
391
+ from adata.elements.write import set_shape_attr
392
+
393
+ set_shape_attr(group, (n_rows, n_cols))
394
+
395
+ dtype = np.result_type(*[s["data"].dtype for s in sources])
396
+ out_data = _growable(group, "data", dtype)
397
+ out_indices = _growable(group, "indices", np.int64)
398
+ indptr = [0]
399
+ nnz = 0
400
+
401
+ for source, col_map in zip(sources, col_maps):
402
+ src_indptr = np.asarray(source["indptr"][...], dtype=np.int64)
403
+ src_data, src_indices = source["data"], source["indices"]
404
+ n_src_rows = len(src_indptr) - 1
405
+
406
+ for start in range(0, n_src_rows, chunk_rows):
407
+ end = min(start + chunk_rows, n_src_rows)
408
+ lo, hi = int(src_indptr[start]), int(src_indptr[end])
409
+ block_idx = (
410
+ np.asarray(src_indices[lo:hi], dtype=np.int64)
411
+ if hi > lo
412
+ else np.empty(0, dtype=np.int64)
413
+ )
414
+ block_data = (
415
+ np.asarray(src_data[lo:hi]) if hi > lo else np.empty(0, dtype=dtype)
416
+ )
417
+
418
+ kept_idx: List[np.ndarray] = []
419
+ kept_data: List[np.ndarray] = []
420
+ for row in range(start, end):
421
+ sl = slice(int(src_indptr[row]) - lo, int(src_indptr[row + 1]) - lo)
422
+ mapped = col_map[block_idx[sl]]
423
+ keep = mapped >= 0
424
+ kept_idx.append(mapped[keep])
425
+ kept_data.append(block_data[sl][keep])
426
+ nnz += int(keep.sum())
427
+ indptr.append(nnz)
428
+
429
+ if kept_idx:
430
+ _append(out_indices, np.concatenate(kept_idx))
431
+ _append(out_data, np.concatenate(kept_data).astype(dtype))
432
+
433
+ create_dataset(group, "indptr", data=np.asarray(indptr, dtype=np.int64))
434
+
435
+
436
+ def _concat_dense(
437
+ dst_parent: Any,
438
+ name: str,
439
+ sources: List[Any],
440
+ col_maps: List[np.ndarray],
441
+ n_rows: int,
442
+ n_cols: int,
443
+ chunk_rows: int,
444
+ fill_value: float,
445
+ ) -> None:
446
+ """Concatenate dense matrices row-wise, scattering columns into place."""
447
+ dtype = np.result_type(*[s.dtype for s in sources])
448
+ if fill_value != 0 and dtype.kind in ("i", "u", "b"):
449
+ dtype = np.dtype("float64")
450
+
451
+ out = create_dataset(dst_parent, name, shape=(n_rows, n_cols), dtype=dtype)
452
+ spec.set_encoding(out, spec.ARRAY)
453
+
454
+ row_offset = 0
455
+ for source, col_map in zip(sources, col_maps):
456
+ keep = col_map >= 0
457
+ targets = col_map[keep]
458
+ n_src_rows = source.shape[0]
459
+
460
+ for start in range(0, n_src_rows, chunk_rows):
461
+ end = min(start + chunk_rows, n_src_rows)
462
+ block = np.full((end - start, n_cols), fill_value, dtype=dtype)
463
+ block[:, targets] = np.asarray(source[start:end, :])[:, keep]
464
+ out[row_offset + start : row_offset + end, :] = block
465
+
466
+ row_offset += n_src_rows
467
+
468
+
469
+ def _inverse_column_map(col_map: np.ndarray, n_cols: int) -> np.ndarray:
470
+ """Invert a source->target column map into target->source, -1 where absent."""
471
+ inverse = np.full(n_cols, -1, dtype=np.int64)
472
+ present = col_map >= 0
473
+ inverse[col_map[present]] = np.nonzero(present)[0]
474
+ return inverse
475
+
476
+
477
+ def _concat_csc(
478
+ dst_parent: Any,
479
+ name: str,
480
+ sources: List[Any],
481
+ col_maps: List[np.ndarray],
482
+ row_counts: List[int],
483
+ n_rows: int,
484
+ n_cols: int,
485
+ ) -> None:
486
+ """Concatenate CSC matrices row-wise, keeping the CSC encoding.
487
+
488
+ A CSC matrix is stored by column, so concatenating along obs means, for
489
+ each target column, appending each input's row indices in turn with that
490
+ input's row offset added. Because every input's column is already sorted
491
+ and the offsets increase, the result is sorted without a re-sort.
492
+ """
493
+ from adata.core.subset import _append, _growable
494
+ from adata.elements.write import set_shape_attr
495
+
496
+ group = dst_parent.create_group(name)
497
+ spec.set_encoding(group, spec.CSC_MATRIX)
498
+ set_shape_attr(group, (n_rows, n_cols))
499
+
500
+ dtype = np.result_type(*[s["data"].dtype for s in sources])
501
+ out_data = _growable(group, "data", dtype)
502
+ out_indices = _growable(group, "indices", np.int64)
503
+ indptr = [0]
504
+ nnz = 0
505
+
506
+ inverses = [_inverse_column_map(cm, n_cols) for cm in col_maps]
507
+ indptrs = [np.asarray(s["indptr"][...], dtype=np.int64) for s in sources]
508
+ row_offsets = np.cumsum([0] + list(row_counts[:-1]))
509
+
510
+ for target_col in range(n_cols):
511
+ rows: List[np.ndarray] = []
512
+ values: List[np.ndarray] = []
513
+ for source, src_indptr, inverse, offset in zip(
514
+ sources, indptrs, inverses, row_offsets
515
+ ):
516
+ src_col = int(inverse[target_col])
517
+ if src_col < 0:
518
+ continue
519
+ lo, hi = int(src_indptr[src_col]), int(src_indptr[src_col + 1])
520
+ if hi <= lo:
521
+ continue
522
+ rows.append(np.asarray(source["indices"][lo:hi], dtype=np.int64) + offset)
523
+ values.append(np.asarray(source["data"][lo:hi]))
524
+
525
+ if rows:
526
+ _append(out_indices, np.concatenate(rows))
527
+ _append(out_data, np.concatenate(values).astype(dtype))
528
+ nnz += int(sum(len(r) for r in rows))
529
+ indptr.append(nnz)
530
+
531
+ create_dataset(group, "indptr", data=np.asarray(indptr, dtype=np.int64))
532
+
533
+
534
+ def check_matrix_encodings(roots: List[Any], console: Console) -> None:
535
+ """Fail before writing anything if a matrix cannot be concatenated.
536
+
537
+ Checked up front rather than mid-write: discovering this half way through
538
+ would leave a partial store behind, and skipping the matrix would produce
539
+ an output silently missing X.
540
+ """
541
+ def _check(label: str, sources: List[Any]) -> None:
542
+ kinds = {_matrix_kind(s) for s in sources}
543
+ if kinds in ({spec.CSR_MATRIX}, {spec.CSC_MATRIX}, {"dense"}):
544
+ return
545
+ raise ValueError(
546
+ f"Cannot concatenate {label!r}: inputs use "
547
+ f"{', '.join(sorted(kinds))}. Every input must use the same "
548
+ "encoding -- convert them to match first."
549
+ )
550
+
551
+ if all("X" in r for r in roots):
552
+ _check("X", [r["X"] for r in roots])
553
+ elif any("X" in r for r in roots):
554
+ console.print("[yellow]Skipping X: not present in every input[/]")
555
+
556
+ names = _index_union(
557
+ [list(r["layers"].keys()) if "layers" in r else [] for r in roots]
558
+ )
559
+ for name in names:
560
+ if all("layers" in r and name in r["layers"] for r in roots):
561
+ _check(f"layers/{name}", [r["layers"][name] for r in roots])
562
+
563
+
564
+ def _concat_matrix(
565
+ dst_parent: Any,
566
+ name: str,
567
+ sources: List[Any],
568
+ col_maps: List[np.ndarray],
569
+ row_counts: List[int],
570
+ n_rows: int,
571
+ n_cols: int,
572
+ chunk_rows: int,
573
+ fill_value: float,
574
+ console: Console,
575
+ ) -> bool:
576
+ """Concatenate X or one layer across inputs. Returns whether it was written.
577
+
578
+ Encodings are validated by :func:`check_matrix_encodings` before the output
579
+ store exists, so anything reaching here is concatenable.
580
+ """
581
+ kinds = {_matrix_kind(s) for s in sources}
582
+
583
+ if kinds == {spec.CSR_MATRIX}:
584
+ _concat_sparse(
585
+ dst_parent, name, sources, col_maps, n_rows, n_cols, chunk_rows
586
+ )
587
+ return True
588
+
589
+ if kinds == {spec.CSC_MATRIX}:
590
+ _concat_csc(
591
+ dst_parent, name, sources, col_maps, row_counts, n_rows, n_cols
592
+ )
593
+ return True
594
+
595
+ if kinds == {"dense"}:
596
+ _concat_dense(
597
+ dst_parent,
598
+ name,
599
+ sources,
600
+ col_maps,
601
+ n_rows,
602
+ n_cols,
603
+ chunk_rows,
604
+ fill_value,
605
+ )
606
+ return True
607
+
608
+ raise ValueError(
609
+ f"Cannot concatenate {name!r}: inputs use {', '.join(sorted(kinds))}."
610
+ )
611
+
612
+
613
+ def _concat_obsm(
614
+ dst_parent: Any,
615
+ name: str,
616
+ sources: List[Any],
617
+ n_rows: int,
618
+ chunk_rows: int,
619
+ console: Console,
620
+ ) -> bool:
621
+ """Concatenate an obsm entry row-wise; its columns are not an axis."""
622
+ if not all(is_dataset(s) for s in sources):
623
+ console.print(f"[yellow]Skipping obsm/{name}: not a dense array in every input[/]")
624
+ return False
625
+
626
+ widths = {tuple(s.shape[1:]) for s in sources}
627
+ if len(widths) != 1:
628
+ console.print(
629
+ f"[yellow]Skipping obsm/{name}: inputs disagree on shape {sorted(widths)}[/]"
630
+ )
631
+ return False
632
+
633
+ trailing = widths.pop()
634
+ dtype = np.result_type(*[s.dtype for s in sources])
635
+ out = create_dataset(
636
+ dst_parent, name, shape=(n_rows,) + trailing, dtype=dtype
637
+ )
638
+ spec.set_encoding(out, spec.ARRAY)
639
+
640
+ offset = 0
641
+ for source in sources:
642
+ for start in range(0, source.shape[0], chunk_rows):
643
+ end = min(start + chunk_rows, source.shape[0])
644
+ out[offset + start : offset + end, ...] = source[start:end, ...]
645
+ offset += source.shape[0]
646
+ return True
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # orchestration
651
+
652
+
653
+ def _merge_group(
654
+ dst_parent: Any,
655
+ name: str,
656
+ groups: List[Optional[Any]],
657
+ strategy: Optional[str],
658
+ console: Console,
659
+ ) -> None:
660
+ """Merge a mapping (uns, varm, ...) across inputs under `strategy`."""
661
+ target = write_mapping(dst_parent, name)
662
+ if strategy is None:
663
+ return
664
+
665
+ keys: List[str] = []
666
+ for group in groups:
667
+ if group is None:
668
+ continue
669
+ for key in group.keys():
670
+ if key not in keys:
671
+ keys.append(key)
672
+
673
+ for key in keys:
674
+ members = [
675
+ g[key] if g is not None and key in g else _MISSING for g in groups
676
+ ]
677
+ present = [m for m in members if m is not _MISSING]
678
+
679
+ if all(is_group(m) for m in present) and strategy in ("same", "unique"):
680
+ # Nested mappings are merged member-wise rather than compared whole.
681
+ if all(spec.encoding_type(m) == spec.DICT for m in present):
682
+ _merge_group(target, key, [
683
+ m if m is not _MISSING else None for m in members
684
+ ], strategy, console)
685
+ continue
686
+
687
+ comparable = [
688
+ _MISSING if m is _MISSING else _readable_value(m) for m in members
689
+ ]
690
+ keep, _ = _merge_values(comparable, strategy)
691
+ if keep:
692
+ source = next(m for m in members if m is not _MISSING)
693
+ copy_tree(source, target, key)
694
+
695
+
696
+ #: Elements larger than this are not compared value-by-value.
697
+ MAX_COMPARABLE_ELEMENTS = 1_000_000
698
+
699
+
700
+ class _Incomparable:
701
+ """Stands for a value too large to compare, and equal to nothing."""
702
+
703
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
704
+ return "<incomparable>"
705
+
706
+
707
+ def _readable_value(obj: Any, budget: Optional[List[int]] = None) -> Any:
708
+ """A comparable snapshot of an element, for the merge strategies.
709
+
710
+ Recurses into groups and reads their datasets, including attributes. Using
711
+ only child key names -- as an earlier version did -- would make two
712
+ dataframes with the same columns but different values compare equal, so
713
+ `same` and `unique` would keep conflicting metadata.
714
+
715
+ Very large elements are reported as incomparable, which equals nothing and
716
+ so is never kept by `same` or `unique`.
717
+ """
718
+ if budget is None:
719
+ budget = [MAX_COMPARABLE_ELEMENTS]
720
+
721
+ try:
722
+ attrs = tuple(
723
+ sorted(
724
+ (str(k), _hashable(spec.decode_attr(v)))
725
+ for k, v in obj.attrs.items()
726
+ if k not in ("encoding-version",)
727
+ )
728
+ )
729
+
730
+ if is_dataset(obj):
731
+ size = int(np.prod(obj.shape)) if obj.shape else 1
732
+ budget[0] -= size
733
+ if budget[0] < 0:
734
+ return _Incomparable()
735
+ return ("dataset", attrs, _hashable(np.asarray(obj[...])))
736
+
737
+ children = []
738
+ for key in sorted(obj.keys()):
739
+ value = _readable_value(obj[key], budget)
740
+ if isinstance(value, _Incomparable):
741
+ return value
742
+ children.append((str(key), value))
743
+ return ("group", attrs, tuple(children))
744
+ except Exception:
745
+ return _Incomparable()
746
+
747
+
748
+ def _hashable(value: Any) -> Any:
749
+ """Reduce a value to something `_equal` can compare reliably."""
750
+ if isinstance(value, np.ndarray):
751
+ return (value.shape, value.dtype.kind, value.tobytes()
752
+ if value.dtype.kind not in ("O", "T") else tuple(map(str, value.reshape(-1).tolist())))
753
+ if isinstance(value, np.generic):
754
+ return value.item()
755
+ if isinstance(value, (list, tuple)):
756
+ return tuple(_hashable(v) for v in value)
757
+ return value
758
+
759
+
760
+ def concat_on_disk(
761
+ files: Sequence[Path],
762
+ output: Path,
763
+ console: Console,
764
+ *,
765
+ join: str = "inner",
766
+ label: Optional[str] = None,
767
+ keys: Optional[Sequence[str]] = None,
768
+ index_unique: Optional[str] = None,
769
+ merge: Optional[str] = None,
770
+ uns_merge: Optional[str] = None,
771
+ fill_value: float = 0.0,
772
+ chunk_rows: int = 1024,
773
+ zarr_format: Optional[int] = None,
774
+ ) -> None:
775
+ """Concatenate stores along the obs axis, streaming each one in turn.
776
+
777
+ `join` aligns var: "inner" keeps only variables present in every input,
778
+ "outer" keeps their union. obs columns follow the same rule. Elements of
779
+ the var axis and of uns are reconciled by `merge`/`uns_merge`, since
780
+ inputs may legitimately disagree about them.
781
+
782
+ obsp and varp are not concatenated -- a pairwise matrix has no meaningful
783
+ value between cells that came from different inputs. anndata's own
784
+ implementation refuses these too.
785
+ """
786
+ if len(files) < 2:
787
+ raise ValueError("Concatenation needs at least two input stores.")
788
+ if join not in ("inner", "outer"):
789
+ raise ValueError("--join must be 'inner' or 'outer'.")
790
+ if output.exists():
791
+ raise FileExistsError(f"'{output}' already exists.")
792
+
793
+ input_keys = _resolve_keys(files, keys)
794
+ stores = [open_store(f, "r") for f in files]
795
+
796
+ try:
797
+ roots = [s.root for s in stores]
798
+
799
+ var_names = [read_names(r, "var") for r in roots]
800
+ obs_names = [read_names(r, "obs") for r in roots]
801
+ obs_counts = [axis_len(r, "obs") for r in roots]
802
+
803
+ target_var = (
804
+ _index_intersection(var_names)
805
+ if join == "inner"
806
+ else _index_union(var_names)
807
+ )
808
+ if not target_var:
809
+ raise ValueError(
810
+ "No variables are shared by all inputs. Use --join outer to "
811
+ "keep their union instead."
812
+ )
813
+
814
+ col_maps = [_column_map(names, target_var) for names in var_names]
815
+ n_obs = sum(obs_counts)
816
+ n_var = len(target_var)
817
+
818
+ console.print(
819
+ f"[cyan]Concatenating {len(files)} stores: "
820
+ f"{n_obs} obs x {n_var} var ({join} join)[/]"
821
+ )
822
+ for f, key, count in zip(files, input_keys, obs_counts):
823
+ console.print(f" [dim]{key}[/]: {count} obs from {f.name}")
824
+
825
+ target_obs: List[str] = []
826
+ for names, key in zip(obs_names, input_keys):
827
+ target_obs.extend(_apply_index_unique(names, key, index_unique))
828
+
829
+ if len(set(target_obs)) != len(target_obs):
830
+ console.print(
831
+ "[yellow]Warning: obs names are not unique across inputs. "
832
+ "Pass --index-unique to disambiguate them.[/]"
833
+ )
834
+
835
+ # Validated before the output exists, so a mismatch cannot leave a
836
+ # half-written store behind.
837
+ check_matrix_encodings(roots, console)
838
+
839
+ with open_store(output, "w", zarr_format=zarr_format) as dst_store:
840
+ dst = dst_store.root
841
+ _write_obs(
842
+ dst, roots, target_obs, obs_counts, join, label, input_keys, console
843
+ )
844
+ _write_var(dst, roots, var_names, target_var, merge, console)
845
+ ensure_anndata_skeleton(dst)
846
+
847
+ _write_matrices(
848
+ dst,
849
+ roots,
850
+ col_maps,
851
+ obs_counts,
852
+ n_obs,
853
+ n_var,
854
+ chunk_rows,
855
+ fill_value,
856
+ console,
857
+ )
858
+ _write_obsm(dst, roots, n_obs, chunk_rows, console)
859
+
860
+ uns_groups = [r["uns"] if "uns" in r else None for r in roots]
861
+ if any(g is not None for g in uns_groups):
862
+ if uns_merge is None:
863
+ write_mapping(dst, "uns")
864
+ else:
865
+ _merge_group(dst, "uns", uns_groups, uns_merge, console)
866
+
867
+ for name in ("obsp", "varp"):
868
+ if any(name in r and len(list(r[name].keys())) for r in roots):
869
+ console.print(
870
+ f"[yellow]Dropping {name}: pairwise values have no "
871
+ f"meaning across concatenated inputs.[/]"
872
+ )
873
+ if any("raw" in r for r in roots):
874
+ console.print("[yellow]Dropping raw/: not concatenated.[/]")
875
+
876
+ console.print(f"[green]Wrote[/] {output} ({n_obs} obs x {n_var} var)")
877
+ finally:
878
+ for store in stores:
879
+ store.close()
880
+
881
+
882
+ def _write_obs(
883
+ dst: Any,
884
+ roots: List[Any],
885
+ target_obs: List[str],
886
+ obs_counts: List[int],
887
+ join: str,
888
+ label: Optional[str],
889
+ input_keys: List[str],
890
+ console: Console,
891
+ ) -> None:
892
+ """Write the concatenated obs frame, plus the batch column if requested."""
893
+ groups = [r["obs"] for r in roots]
894
+ per_input_cols = [
895
+ dataframe_columns(g, resolve_index(g, "obs")[1]) for g in groups
896
+ ]
897
+
898
+ if join == "inner":
899
+ shared = set(per_input_cols[0]).intersection(*map(set, per_input_cols[1:]))
900
+ names = [c for c in per_input_cols[0] if c in shared]
901
+ else:
902
+ names = _index_union(per_input_cols)
903
+
904
+ written: List[str] = []
905
+ obs_group = write_dataframe_header(dst, "obs", target_obs, [])
906
+
907
+ for name in names:
908
+ columns = [g[name] if name in g else None for g in groups]
909
+ if _concat_obs_column(obs_group, name, columns, obs_counts, console):
910
+ written.append(name)
911
+
912
+ if label:
913
+ if label in written:
914
+ raise ValueError(
915
+ f"--label {label!r} collides with an existing obs column."
916
+ )
917
+ codes = np.concatenate(
918
+ [np.full(n, i, dtype=np.int64) for i, n in enumerate(obs_counts)]
919
+ )
920
+ write_categorical(obs_group, label, codes, input_keys, ordered=False)
921
+ written.append(label)
922
+
923
+ set_column_order(obs_group, written)
924
+
925
+
926
+ def _write_var(
927
+ dst: Any,
928
+ roots: List[Any],
929
+ var_names: List[List[str]],
930
+ target_var: List[str],
931
+ merge: Optional[str],
932
+ console: Console,
933
+ ) -> None:
934
+ """Write the aligned var frame, keeping columns the merge strategy allows."""
935
+ var_group = write_dataframe_header(dst, "var", target_var, [])
936
+ if merge is None:
937
+ return
938
+
939
+ groups = [r["var"] for r in roots]
940
+ per_input_cols = [
941
+ dataframe_columns(g, resolve_index(g, "var")[1]) for g in groups
942
+ ]
943
+ candidates = _index_union(per_input_cols)
944
+
945
+ positions = [_column_map(target_var, names) for names in var_names]
946
+ written: List[str] = []
947
+
948
+ for name in candidates:
949
+ aligned: List[Any] = []
950
+ for group, where in zip(groups, positions):
951
+ if name not in group or (where < 0).any():
952
+ aligned.append(_MISSING)
953
+ continue
954
+ aligned.append(tuple(read_str_all(group[name])[i] for i in where))
955
+
956
+ keep, _ = _merge_values(aligned, merge)
957
+ if not keep:
958
+ continue
959
+
960
+ source_i = next(
961
+ i for i, v in enumerate(aligned) if v is not _MISSING
962
+ )
963
+ column = groups[source_i][name]
964
+ take = positions[source_i]
965
+ _write_var_column(var_group, name, column, take)
966
+ written.append(name)
967
+
968
+ set_column_order(var_group, written)
969
+
970
+
971
+ def _write_var_column(
972
+ parent: Any, name: str, column: Any, take: np.ndarray
973
+ ) -> None:
974
+ """Write one var column, reordered onto the target var index.
975
+
976
+ Each encoding is rewritten as itself. Rendering everything as text -- as an
977
+ earlier version did for nullable columns -- turned missing values into
978
+ empty strings and lost the numeric and boolean dtypes.
979
+ """
980
+ kind = _column_kind(column)
981
+
982
+ if kind == "categorical":
983
+ categories = [str(c) for c in read_categories(column)]
984
+ codes = np.asarray(column["codes"][...], dtype=np.int64)[take]
985
+ write_categorical(
986
+ parent, name, codes, categories, ordered=is_ordered(column)
987
+ )
988
+ return
989
+
990
+ if kind == "numeric":
991
+ write_dense(parent, name, np.asarray(column[...])[take])
992
+ return
993
+
994
+ if kind == "masked":
995
+ enc = spec.encoding_type(column) or spec.NULLABLE_STRING_ARRAY
996
+ mask = np.asarray(column["mask"][...], dtype=bool)[take]
997
+ raw_values = np.asarray(column["values"][...])
998
+ if enc == spec.NULLABLE_STRING_ARRAY:
999
+ from adata.elements.read import decode_str_array
1000
+
1001
+ values = decode_str_array(raw_values)[take].tolist()
1002
+ else:
1003
+ values = raw_values[take]
1004
+ na_value = spec.decode_attr(column.attrs.get("na-value", None))
1005
+ write_masked(parent, name, values, mask, enc, na_value=na_value)
1006
+ return
1007
+
1008
+ values = read_str_all(column)
1009
+ write_string_array(parent, name, [values[i] for i in take])
1010
+
1011
+
1012
+ def _write_matrices(
1013
+ dst: Any,
1014
+ roots: List[Any],
1015
+ col_maps: List[np.ndarray],
1016
+ row_counts: List[int],
1017
+ n_obs: int,
1018
+ n_var: int,
1019
+ chunk_rows: int,
1020
+ fill_value: float,
1021
+ console: Console,
1022
+ ) -> None:
1023
+ """Concatenate X and every layer shared by all inputs."""
1024
+ if all("X" in r for r in roots):
1025
+ _concat_matrix(
1026
+ dst,
1027
+ "X",
1028
+ [r["X"] for r in roots],
1029
+ col_maps,
1030
+ row_counts,
1031
+ n_obs,
1032
+ n_var,
1033
+ chunk_rows,
1034
+ fill_value,
1035
+ console,
1036
+ )
1037
+
1038
+ layer_names = _index_union(
1039
+ [list(r["layers"].keys()) if "layers" in r else [] for r in roots]
1040
+ )
1041
+ if not layer_names:
1042
+ return
1043
+
1044
+ layers = write_mapping(dst, "layers")
1045
+ for name in layer_names:
1046
+ if not all("layers" in r and name in r["layers"] for r in roots):
1047
+ console.print(
1048
+ f"[yellow]Skipping layer {name!r}: not present in every input[/]"
1049
+ )
1050
+ continue
1051
+ _concat_matrix(
1052
+ layers,
1053
+ name,
1054
+ [r["layers"][name] for r in roots],
1055
+ col_maps,
1056
+ row_counts,
1057
+ n_obs,
1058
+ n_var,
1059
+ chunk_rows,
1060
+ fill_value,
1061
+ console,
1062
+ )
1063
+
1064
+
1065
+ def _write_obsm(
1066
+ dst: Any, roots: List[Any], n_obs: int, chunk_rows: int, console: Console
1067
+ ) -> None:
1068
+ """Concatenate obsm entries shared by all inputs."""
1069
+ names = _index_union(
1070
+ [list(r["obsm"].keys()) if "obsm" in r else [] for r in roots]
1071
+ )
1072
+ if not names:
1073
+ return
1074
+
1075
+ obsm = write_mapping(dst, "obsm")
1076
+ for name in names:
1077
+ if not all("obsm" in r and name in r["obsm"] for r in roots):
1078
+ console.print(
1079
+ f"[yellow]Skipping obsm/{name}: not present in every input[/]"
1080
+ )
1081
+ continue
1082
+ _concat_obsm(
1083
+ obsm, name, [r["obsm"][name] for r in roots], n_obs, chunk_rows, console
1084
+ )