pyadata-cli 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ """`split` -- write one store per distinct value of an annotation column.
2
+
3
+ Requested in issue #2, modelled on cellgeni/scraft's `split_h5ad` but
4
+ streaming: the source is never loaded into memory, only the column being split
5
+ on is read, and each output is produced by the same subset machinery used by
6
+ `adata subset`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Tuple
15
+
16
+ import numpy as np
17
+ from rich.console import Console
18
+
19
+ from adata.core.select import group_indices
20
+ from adata.core.subset import subset_h5ad
21
+ from adata.storage import detect_backend, open_store
22
+
23
+
24
+ def sanitize_label(label: str) -> str:
25
+ """Turn a column value into something safe to use as a filename."""
26
+ text = (label or "").strip()
27
+ if not text:
28
+ return "NA"
29
+ text = text.replace("/", "_").replace("\\", "_")
30
+ text = re.sub(r"\s+", "_", text)
31
+ text = re.sub(r"[^A-Za-z0-9_.-]+", "", text)
32
+ return text or "NA"
33
+
34
+
35
+ def unique_names(labels: List[str]) -> Dict[str, str]:
36
+ """Map each label to a distinct filename stem.
37
+
38
+ Sanitising can map two different labels onto the same stem ("a/b" and
39
+ "a b"), so collisions get a numeric suffix rather than silently
40
+ overwriting one another.
41
+ """
42
+ used: Dict[str, int] = {}
43
+ mapping: Dict[str, str] = {}
44
+ for label in labels:
45
+ base = sanitize_label(label)
46
+ count = used.get(base, 0)
47
+ used[base] = count + 1
48
+ mapping[label] = base if count == 0 else f"{base}_{count}"
49
+ return mapping
50
+
51
+
52
+ def split_store(
53
+ file: Path,
54
+ column: str,
55
+ output_dir: Path,
56
+ console: Console,
57
+ axis: str = "obs",
58
+ suffix: Optional[str] = None,
59
+ dry_run: bool = False,
60
+ manifest: bool = True,
61
+ min_size: int = 1,
62
+ chunk_rows: int = 1024,
63
+ zarr_format: Optional[int] = None,
64
+ ) -> List[Tuple[str, Path, int]]:
65
+ """Split `file` into one store per distinct value of `column`.
66
+
67
+ Returns ``(label, path, n_rows)`` for each group written. Groups smaller
68
+ than `min_size` are skipped with a warning rather than producing tiny
69
+ stores nobody asked for.
70
+
71
+ `zarr_format` overrides the Zarr version of the outputs; without it they
72
+ follow the source store's.
73
+ """
74
+ if axis not in ("obs", "var"):
75
+ raise ValueError("--axis must be 'obs' or 'var'.")
76
+
77
+ if suffix is None:
78
+ suffix = ".zarr" if detect_backend(file) == "zarr" else ".h5ad"
79
+
80
+ with open_store(file, "r") as store:
81
+ groups, order = group_indices(store.root, axis, column)
82
+ if zarr_format is None:
83
+ zarr_format = store.zarr_format
84
+
85
+ if not groups:
86
+ raise ValueError(f"Column {column!r} produced no groups.")
87
+
88
+ names = unique_names(order)
89
+ console.print(
90
+ f"[cyan]Splitting on {axis}[{column}]: "
91
+ f"{len(order)} group{'s' if len(order) != 1 else ''}[/]"
92
+ )
93
+
94
+ planned: List[Tuple[str, Path, int]] = []
95
+ for label in order:
96
+ indices = groups[label]
97
+ out_path = output_dir / f"{names[label]}{suffix}"
98
+ if len(indices) < min_size:
99
+ console.print(
100
+ f"[yellow]Skipping {label!r}: {len(indices)} "
101
+ f"{axis} < --min-size {min_size}[/]"
102
+ )
103
+ continue
104
+ planned.append((label, out_path, len(indices)))
105
+
106
+ for label, out_path, count in planned:
107
+ console.print(f" {label!r} -> {out_path} ({count} {axis})")
108
+
109
+ if dry_run:
110
+ console.print("[yellow]Dry run: nothing written.[/]")
111
+ return planned
112
+
113
+ output_dir.mkdir(parents=True, exist_ok=True)
114
+
115
+ for label, out_path, _ in planned:
116
+ indices = np.sort(groups[label])
117
+ subset_h5ad(
118
+ file=file,
119
+ output=out_path,
120
+ obs_file=None,
121
+ var_file=None,
122
+ chunk_rows=chunk_rows,
123
+ console=console,
124
+ obs_indices=indices if axis == "obs" else None,
125
+ var_indices=indices if axis == "var" else None,
126
+ zarr_format=zarr_format,
127
+ )
128
+
129
+ if manifest:
130
+ _write_manifest(file, output_dir, column, planned, console)
131
+
132
+ console.print(f"[green]Wrote[/] {len(planned)} stores to {output_dir}")
133
+ return planned
134
+
135
+
136
+ def _write_manifest(
137
+ file: Path,
138
+ output_dir: Path,
139
+ column: str,
140
+ planned: List[Tuple[str, Path, int]],
141
+ console: Console,
142
+ ) -> Path:
143
+ """Record what was written, for downstream pipelines to consume."""
144
+ source_id = file.stem if file.suffix else file.name
145
+ path = output_dir / f"{source_id}_manifest.csv"
146
+ with open(path, "w", newline="", encoding="utf-8") as fh:
147
+ writer = csv.writer(fh)
148
+ writer.writerow(["id", "column", "value", "anndatas", "n"])
149
+ for label, out_path, count in planned:
150
+ writer.writerow([source_id, column, label, str(out_path), count])
151
+ console.print(f"[dim]Manifest written to {path}[/]")
152
+ return path
@@ -0,0 +1,17 @@
1
+ from adata.core.subset import (
2
+ _read_name_file,
3
+ indices_from_name_set,
4
+ subset_axis_group,
5
+ subset_dense_matrix,
6
+ subset_h5ad,
7
+ subset_sparse_matrix_group,
8
+ )
9
+
10
+ __all__ = [
11
+ "_read_name_file",
12
+ "indices_from_name_set",
13
+ "subset_axis_group",
14
+ "subset_dense_matrix",
15
+ "subset_h5ad",
16
+ "subset_sparse_matrix_group",
17
+ ]
adata/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core logic shared by CLI commands and format handlers."""