pyadata-cli 0.9.0__tar.gz → 0.10.0__tar.gz

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.
Files changed (58) hide show
  1. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/PKG-INFO +2 -1
  2. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/README.md +1 -0
  3. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/pyproject.toml +1 -1
  4. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/cli.py +33 -6
  5. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/__init__.py +9 -1
  6. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/base.py +6 -1
  7. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/features.py +9 -3
  8. pyadata_cli-0.10.0/src/adata/conversion/parse.py +404 -0
  9. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/write.py +16 -0
  10. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/mtx.py +8 -1
  11. pyadata_cli-0.10.0/src/adata/formats/table.py +364 -0
  12. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/util/names.py +18 -1
  13. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/LICENSE +0 -0
  14. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/__init__.py +0 -0
  15. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/__init__.py +0 -0
  16. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/concat.py +0 -0
  17. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/convert.py +0 -0
  18. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/create.py +0 -0
  19. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/export.py +0 -0
  20. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/import_data.py +0 -0
  21. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/info.py +0 -0
  22. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/ls.py +0 -0
  23. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/recast.py +0 -0
  24. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/split.py +0 -0
  25. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/subset.py +0 -0
  26. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/anndata_copy.py +0 -0
  27. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/cellbender.py +0 -0
  28. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/mtx.py +0 -0
  29. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/tenx.py +0 -0
  30. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/__init__.py +0 -0
  31. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/concat.py +0 -0
  32. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/csr_build.py +0 -0
  33. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/info.py +0 -0
  34. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/query.py +0 -0
  35. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/read.py +0 -0
  36. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/recast.py +0 -0
  37. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/select.py +0 -0
  38. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/subset.py +0 -0
  39. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/__init__.py +0 -0
  40. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/read.py +0 -0
  41. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/spec.py +0 -0
  42. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/strings.py +0 -0
  43. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/__init__.py +0 -0
  44. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/array.py +0 -0
  45. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/common.py +0 -0
  46. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/dataframe.py +0 -0
  47. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/image.py +0 -0
  48. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/json_data.py +0 -0
  49. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/sparse.py +0 -0
  50. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/textio.py +0 -0
  51. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/validate.py +0 -0
  52. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/info.py +0 -0
  53. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/py.typed +0 -0
  54. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/read.py +0 -0
  55. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/storage/__init__.py +0 -0
  56. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/util/__init__.py +0 -0
  57. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/util/path.py +0 -0
  58. {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/util/staging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyadata-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Streaming CLI for exploring and editing large AnnData .h5ad and .zarr stores
5
5
  Keywords: anndata,h5ad,zarr,bioinformatics,single-cell,cli
6
6
  Author: Aljes Binkevich
@@ -141,6 +141,7 @@ adata convert data.zarr -o data.h5ad --compression gzip
141
141
  adata convert filtered_feature_bc_matrix.h5 -o counts.h5ad --feature-type "Gene Expression"
142
142
  adata convert counts.h5ad -o for_cellbender.h5 --layer counts
143
143
  adata convert Solo.out/Velocyto/raw -o velocyto.h5ad
144
+ adata convert splitpipe_out -o parse.h5ad --unfiltered
144
145
  ```
145
146
 
146
147
  ## Documentation
@@ -101,6 +101,7 @@ adata convert data.zarr -o data.h5ad --compression gzip
101
101
  adata convert filtered_feature_bc_matrix.h5 -o counts.h5ad --feature-type "Gene Expression"
102
102
  adata convert counts.h5ad -o for_cellbender.h5 --layer counts
103
103
  adata convert Solo.out/Velocyto/raw -o velocyto.h5ad
104
+ adata convert splitpipe_out -o parse.h5ad --unfiltered
104
105
  ```
105
106
 
106
107
  ## Documentation
@@ -3,7 +3,7 @@
3
3
  # so the distribution is published as `pyadata-cli`. The import package
4
4
  # and the command are both still `adata`.
5
5
  name = "pyadata-cli"
6
- version = "0.9.0"
6
+ version = "0.10.0"
7
7
  description = "Streaming CLI for exploring and editing large AnnData .h5ad and .zarr stores"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
@@ -524,6 +524,9 @@ _CONVERT_FLAGS = {
524
524
  "feature_types_column": "--feature-types-column",
525
525
  "raw": "--raw",
526
526
  "min_cell_probability": "--min-cell-probability",
527
+ "sample": "--sample",
528
+ "all_samples": "--all-samples",
529
+ "unfiltered": "--unfiltered",
527
530
  }
528
531
 
529
532
  #: Flags that only make sense for format conversion; with recast's flags,
@@ -608,9 +611,10 @@ def convert(
608
611
  "Cell Ranger 2 matrix)",
609
612
  ),
610
613
  var_names: str = typer.Option(
611
- "gene_symbols",
614
+ "gene_ids",
612
615
  "--var-names",
613
- help="10x input: name features by gene_symbols or gene_ids",
616
+ help="10x input: name features by gene_ids (default; unique) or "
617
+ "gene_symbols (scanpy's default)",
614
618
  ),
615
619
  make_unique: bool = typer.Option(
616
620
  False,
@@ -662,6 +666,21 @@ def convert(
662
666
  help="CellBender input: keep droplets with cell_probability at least "
663
667
  "this (CellBender's own cutoff is 0.5)",
664
668
  ),
669
+ sample: Optional[str] = typer.Option(
670
+ None,
671
+ "--sample",
672
+ help="Parse input: the sample folder to read (default: all-sample)",
673
+ ),
674
+ all_samples: bool = typer.Option(
675
+ False,
676
+ "--all-samples",
677
+ help="Parse input: write one output per sample, OUT.<sample>.h5ad",
678
+ ),
679
+ unfiltered: bool = typer.Option(
680
+ False,
681
+ "--unfiltered",
682
+ help="Parse input: read DGE_unfiltered (every barcode) not DGE_filtered",
683
+ ),
665
684
  # Deprecated: recast's flags, so `convert` keeps working as it did until
666
685
  # 1.0.0. Hidden from --help; the old matrix paths arrive in ctx.args.
667
686
  inplace: bool = typer.Option(False, "--inplace", hidden=True),
@@ -680,10 +699,13 @@ def convert(
680
699
  writes a Matrix Market folder. Between h5ad and zarr every element is
681
700
  copied, streamed, keeping the source's chunking and compression where the
682
701
  target can express them. CellBender output keeps its latent variables, as
683
- CellBender's own loader names them. A 10x matrix is read as scanpy's read_10x_h5 or
684
- read_10x_mtx (gex_only=False) reads it, keeping integer counts, and
685
- written as Cell Ranger writes it. MTX folders from Cell Ranger and
686
- STARsolo, velocyto's included, are read in two streaming passes.
702
+ CellBender's own loader names them. A 10x matrix is read as scanpy's
703
+ read_10x_h5 or read_10x_mtx (gex_only=False) reads it, keeping integer
704
+ counts, and written as Cell Ranger writes it. MTX folders from Cell
705
+ Ranger and STARsolo, velocyto's included, and Parse split-pipe output
706
+ are read in two streaming passes. Features are named by gene id, unique
707
+ but in probe-level (Flex) matrices; --var-names gene_symbols names them
708
+ as scanpy does.
687
709
 
688
710
  Nothing is written until every check has passed, and the output is moved
689
711
  into place only once complete: a failed conversion leaves nothing
@@ -704,6 +726,8 @@ def convert(
704
726
  adata convert counts.h5ad -o counts_mtx --to 10x-mtx --layer counts
705
727
  adata convert cellbender_output.h5 -o cb.h5ad --raw raw_feature_bc_matrix.h5
706
728
  adata convert cellbender_output.h5 -o cells.h5ad --min-cell-probability 0.5
729
+ adata convert splitpipe_out -o parse.h5ad
730
+ adata convert splitpipe_out -o parse.h5ad --all-samples --unfiltered
707
731
  """
708
732
  entries = list(ctx.args)
709
733
  recast_flags = _given(ctx, _RECAST_FLAGS)
@@ -789,6 +813,9 @@ def convert(
789
813
  feature_types_column=feature_types_column,
790
814
  raw=raw,
791
815
  min_cell_probability=min_cell_probability,
816
+ sample=sample,
817
+ all_samples=all_samples,
818
+ unfiltered=unfiltered,
792
819
  ),
793
820
  )
794
821
  except Exception as e:
@@ -17,7 +17,7 @@ from typing import Dict, FrozenSet, List, Optional
17
17
 
18
18
  import h5py
19
19
 
20
- from adata.conversion import cellbender, mtx, tenx
20
+ from adata.conversion import cellbender, mtx, parse, tenx
21
21
  from adata.conversion.anndata_copy import AnnDataCopy
22
22
  from adata.conversion.base import ConversionPlan, Converter, Format, Options
23
23
  from adata.storage import has_valid_anndata_root_attrs, is_zarr_path
@@ -92,6 +92,14 @@ FORMATS: Dict[str, Format] = {
92
92
  importer=mtx.MtxImport(),
93
93
  exporter=mtx.MtxExport(),
94
94
  ),
95
+ # A folder too; read only.
96
+ "parse": Format(
97
+ name="parse",
98
+ description="Parse Biosciences split-pipe output (a run, sample or DGE folder)",
99
+ extensions=(),
100
+ sniff=parse.sniff,
101
+ importer=parse.ParseImport(),
102
+ ),
95
103
  }
96
104
 
97
105
  _ANNDATA_COPY = AnnDataCopy()
@@ -24,7 +24,7 @@ class Options:
24
24
 
25
25
  # Reading a 10x matrix.
26
26
  genome: Optional[str] = None
27
- var_names: str = "gene_symbols"
27
+ var_names: str = "gene_ids"
28
28
  make_unique: bool = False
29
29
  feature_type: Optional[str] = None
30
30
  split_feature_types: bool = False
@@ -39,6 +39,11 @@ class Options:
39
39
  raw: Optional[Path] = None
40
40
  min_cell_probability: Optional[float] = None
41
41
 
42
+ # Reading Parse split-pipe output.
43
+ sample: Optional[str] = None
44
+ all_samples: bool = False
45
+ unfiltered: bool = False
46
+
42
47
 
43
48
  @dataclass
44
49
  class ConversionPlan:
@@ -40,7 +40,12 @@ from adata.elements.write import (
40
40
  write_string_array,
41
41
  )
42
42
  from adata.storage import create_dataset, is_group, is_zarr_group, open_store
43
- from adata.util.names import count_duplicates, make_index_unique, unique_names
43
+ from adata.util.names import (
44
+ count_duplicates,
45
+ make_index_unique,
46
+ natural_sorted,
47
+ unique_names,
48
+ )
44
49
 
45
50
  VAR_NAMES = ("gene_symbols", "gene_ids")
46
51
 
@@ -218,9 +223,10 @@ def write_var(root: Any, target: Target) -> None:
218
223
  if values.dtype.kind in "biuf":
219
224
  write_dense(var, key, values)
220
225
  continue
221
- categories, codes = np.unique(values, return_inverse=True)
226
+ categories = natural_sorted(set(values.tolist()))
222
227
  if len(categories) < len(values):
223
- write_categorical(var, key, codes, list(categories))
228
+ lookup = {c: i for i, c in enumerate(categories)}
229
+ write_categorical(var, key, [lookup[v] for v in values.tolist()], categories)
224
230
  else:
225
231
  write_string_array(var, key, values)
226
232
 
@@ -0,0 +1,404 @@
1
+ """Parse Biosciences split-pipe output.
2
+
3
+ split-pipe writes, for the combined run and for each sample it was told of:
4
+
5
+ <run>/all-sample/DGE_filtered/{count_matrix.mtx, all_genes.csv, cell_metadata.csv}
6
+ <run>/all-sample/DGE_unfiltered/ (every barcode, not only cells)
7
+ <run>/<sample>/DGE_filtered/ ...
8
+
9
+ (`all-well` rather than `all-sample` in older versions.) The matrix is
10
+ cells x genes, the other way round from 10x, so each file row is a row of X.
11
+ `all_genes.csv` holds `gene_id,gene_name,genome`, and `cell_metadata.csv`
12
+ starts with `bc_wells`, the barcode, followed by the sample, the species and
13
+ per-cell counts.
14
+
15
+ The result matches Parse's scanpy recipe -- `read_mtx` plus `pd.read_csv`
16
+ of both tables, indexed by `bc_wells` and `gene_name` -- except that
17
+ features are named by gene id unless `--var-names gene_symbols` is given,
18
+ and integer counts stay integers.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from contextlib import ExitStack
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import List, Optional, Sequence
27
+
28
+ import numpy as np
29
+ from rich.console import Console
30
+
31
+ from adata.conversion.base import ConversionPlan, Converter, Options
32
+ from adata.conversion.features import (
33
+ Target,
34
+ check_import_options,
35
+ plan_targets,
36
+ write_var,
37
+ )
38
+ from adata.core.csr_build import CsrAppender
39
+ from adata.elements.write import ensure_anndata_skeleton
40
+ from adata.formats.mtx import EntryMap, MtxHeader, read_header, read_into, scan, value_dtype
41
+ from adata.formats.table import TableScan, read_small_table, scan_table, write_table
42
+ from adata.storage import COMPRESSIONS, open_store
43
+ from adata.util.names import unique_names
44
+ from adata.util.path import is_zarr_path
45
+
46
+ _FILTERED, _UNFILTERED = "DGE_filtered", "DGE_unfiltered"
47
+
48
+ #: The combined folder, by split-pipe version.
49
+ _COMBINED = ("all-sample", "all-well")
50
+
51
+ _INDEX = "bc_wells"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # finding the files
56
+
57
+
58
+ @dataclass
59
+ class _Dge:
60
+ path: Path
61
+ matrix: Path
62
+ genes: Path
63
+ cells: Path
64
+
65
+ @property
66
+ def sample(self) -> str:
67
+ return self.path.parent.name
68
+
69
+ @property
70
+ def filtered(self) -> bool:
71
+ return self.path.name != _UNFILTERED
72
+
73
+
74
+ def _dge(folder: Path) -> Optional[_Dge]:
75
+ """The DGE folder `folder` is, or None."""
76
+ if not folder.is_dir() or is_zarr_path(folder):
77
+ return None
78
+ matrix = next(
79
+ (p for p in (folder / "count_matrix.mtx", folder / "count_matrix.mtx.gz") if p.is_file()),
80
+ None,
81
+ )
82
+ genes, cells = folder / "all_genes.csv", folder / "cell_metadata.csv"
83
+ if matrix is None or not genes.is_file() or not cells.is_file():
84
+ return None
85
+ return _Dge(folder, matrix, genes, cells)
86
+
87
+
88
+ def _sample(folder: Path, which: str) -> Optional[_Dge]:
89
+ """A sample folder's DGE folder of kind `which`, or None."""
90
+ return _dge(folder / which) if folder.is_dir() else None
91
+
92
+
93
+ def _is_sample(folder: Path) -> bool:
94
+ return any(_sample(folder, which) is not None for which in (_FILTERED, _UNFILTERED))
95
+
96
+
97
+ def _children(path: Path) -> List[Path]:
98
+ return sorted(p for p in path.iterdir() if p.is_dir()) if path.is_dir() else []
99
+
100
+
101
+ def sniff(path: Path) -> bool:
102
+ path = Path(path)
103
+ if not path.is_dir() or is_zarr_path(path):
104
+ return False
105
+ # A folder of several runs is recognised too, so `locate` can say to
106
+ # convert them one at a time rather than "unknown format".
107
+ return (
108
+ _dge(path) is not None
109
+ or _is_sample(path)
110
+ or any(
111
+ _is_sample(child) or any(_is_sample(g) for g in _children(child))
112
+ for child in _children(path)
113
+ )
114
+ )
115
+
116
+
117
+ def locate(path: Path, options: Options) -> List[_Dge]:
118
+ """The DGE folders to convert: one, or one per sample."""
119
+ path = Path(path)
120
+ which = _UNFILTERED if options.unfiltered else _FILTERED
121
+ picking = options.sample is not None or options.all_samples
122
+
123
+ found = _dge(path)
124
+ if found is not None:
125
+ if options.unfiltered or picking:
126
+ flag = "--unfiltered" if options.unfiltered else (
127
+ "--sample" if options.sample is not None else "--all-samples"
128
+ )
129
+ raise ValueError(
130
+ f"{path.name} is a DGE folder already, so {flag} has nothing to "
131
+ "choose from; give the split-pipe output folder instead."
132
+ )
133
+ return [found]
134
+
135
+ if _is_sample(path):
136
+ if picking:
137
+ raise ValueError(
138
+ f"{path.name} is a single sample's folder; give the split-pipe "
139
+ "output folder to choose samples."
140
+ )
141
+ return [_need(path, which)]
142
+
143
+ samples = {child.name: child for child in _children(path) if _is_sample(child)}
144
+ if not samples:
145
+ runs = [child.name for child in _children(path) if any(
146
+ _is_sample(grandchild) for grandchild in _children(child)
147
+ )]
148
+ if runs:
149
+ raise ValueError(
150
+ f"{path} holds {len(runs)} split-pipe runs ({', '.join(runs[:5])}"
151
+ f"{', ...' if len(runs) > 5 else ''}); convert one at a time."
152
+ )
153
+ raise ValueError(
154
+ f"{path} is not split-pipe output: it needs count_matrix.mtx, "
155
+ "all_genes.csv and cell_metadata.csv, in DGE_filtered/ or "
156
+ "DGE_unfiltered/ under each sample."
157
+ )
158
+
159
+ named = [name for name in samples if name not in _COMBINED]
160
+ if options.all_samples:
161
+ if not named:
162
+ raise ValueError(
163
+ f"{path.name} holds only the combined {', '.join(samples)} folder; "
164
+ "split-pipe was run without samples."
165
+ )
166
+ return [_need(samples[name], which) for name in named]
167
+ if options.sample is not None:
168
+ if options.sample not in samples:
169
+ raise ValueError(
170
+ f"No sample {options.sample!r}; this run has: {', '.join(samples)}."
171
+ )
172
+ return [_need(samples[options.sample], which)]
173
+ combined = next((name for name in _COMBINED if name in samples), None)
174
+ if combined is None and len(samples) == 1:
175
+ combined = next(iter(samples))
176
+ if combined is None:
177
+ raise ValueError(
178
+ f"{path.name} holds {len(samples)} samples ({', '.join(samples)}) and "
179
+ "no combined all-sample folder; pick one with --sample, or write "
180
+ "each with --all-samples."
181
+ )
182
+ return [_need(samples[combined], which)]
183
+
184
+
185
+ def _need(sample: Path, which: str) -> _Dge:
186
+ found = _sample(sample, which)
187
+ if found is None:
188
+ other = _UNFILTERED if which == _FILTERED else _FILTERED
189
+ hint = (
190
+ f"; it has {other}/" + (" (pass --unfiltered)" if other == _UNFILTERED else "")
191
+ if _sample(sample, other) is not None else ""
192
+ )
193
+ raise ValueError(f"{sample.name} has no readable {which}/{hint}.")
194
+ return found
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # import
199
+
200
+
201
+ @dataclass
202
+ class _Item:
203
+ dge: _Dge
204
+ header: MtxHeader
205
+ cells: TableScan
206
+ rows: Optional[np.ndarray]
207
+ target: Target
208
+ emap: EntryMap
209
+
210
+
211
+ @dataclass
212
+ class _Import:
213
+ items: List[_Item]
214
+ compression: Optional[str]
215
+ zarr_format: Optional[int]
216
+
217
+
218
+ class ParseImport(Converter):
219
+ options = frozenset({
220
+ "--genome", "--var-names", "--make-unique", "--compression",
221
+ "--sample", "--all-samples", "--unfiltered",
222
+ })
223
+
224
+ def plan(
225
+ self,
226
+ source: Path,
227
+ output: Path,
228
+ src_format: str,
229
+ dst_format: str,
230
+ options: Options,
231
+ ) -> ConversionPlan:
232
+ check_import_options(options)
233
+ if options.sample is not None and options.all_samples:
234
+ raise ValueError(
235
+ "--sample picks one sample and --all-samples writes every one; "
236
+ "pass one or the other."
237
+ )
238
+ backend = "zarr" if dst_format == "zarr" else "hdf5"
239
+ if options.compression is not None and options.compression not in COMPRESSIONS[backend]:
240
+ raise ValueError(
241
+ f"--compression {options.compression} is not available for "
242
+ f"{dst_format}; use one of: {', '.join(COMPRESSIONS[backend])}."
243
+ )
244
+
245
+ dges = locate(Path(source), options)
246
+ stems = unique_names([dge.sample for dge in dges])
247
+ items: List[_Item] = []
248
+ outputs: List[Path] = []
249
+ warnings: List[str] = []
250
+ for dge in dges:
251
+ path = output
252
+ if options.all_samples:
253
+ path = output.with_name(f"{output.stem}.{stems[dge.sample]}{output.suffix}")
254
+ item, notes = _plan_item(dge, path, options)
255
+ items.append(item)
256
+ outputs.append(path)
257
+ warnings += [f"{dge.sample}: {n}" if options.all_samples else n for n in notes]
258
+
259
+ first = items[0]
260
+ n_cells = first.cells.n_rows if first.rows is None else first.rows.size
261
+ kept = first.target.keep
262
+ described = (
263
+ f"{n_cells:,} cells x "
264
+ f"{first.header.n_cols if kept is None else kept.size:,} genes"
265
+ )
266
+ if options.genome is not None:
267
+ described += f" of {options.genome}"
268
+ if len(items) > 1:
269
+ described = f"{len(items)} samples, one output each ({', '.join(d.sample for d in dges)})"
270
+ else:
271
+ described += f", {first.header.nnz:,} entries"
272
+ kind = "filtered" if first.dge.filtered else "unfiltered"
273
+ where = "" if len(items) > 1 else f"{first.dge.sample}, "
274
+ return ConversionPlan(
275
+ source=source,
276
+ src_format=src_format,
277
+ dst_format=dst_format,
278
+ outputs=outputs,
279
+ summary=f"Parse split-pipe ({where}{kind}) → {dst_format}, {described}",
280
+ warnings=warnings,
281
+ detail=_Import(
282
+ items=items,
283
+ compression=options.compression or ("gzip" if backend == "hdf5" else None),
284
+ zarr_format=(options.zarr_format or 3) if dst_format == "zarr" else None,
285
+ ),
286
+ )
287
+
288
+ def write(
289
+ self, plan: ConversionPlan, staged: Sequence[Path], console: Console
290
+ ) -> None:
291
+ detail: _Import = plan.detail
292
+
293
+ # Pass 1 of every matrix, before any output exists: a file that
294
+ # cannot be read must not leave a store holding obs and var alone.
295
+ scans = []
296
+ for item in detail.items:
297
+ with console.status(f"[cyan]Checking {_label(item)}...[/]"):
298
+ scans.append(scan(item.header, item.emap))
299
+
300
+ for item, scanned, path in zip(detail.items, scans, staged):
301
+ with ExitStack() as stack:
302
+ root = stack.enter_context(
303
+ open_store(path, "w", zarr_format=detail.zarr_format)
304
+ ).root
305
+ with console.status(f"[cyan]Writing obs and var of {item.dge.sample}...[/]"):
306
+ write_table(root, "obs", item.cells, rows=item.rows)
307
+ write_var(root, item.target)
308
+ ensure_anndata_skeleton(root)
309
+ appender = CsrAppender(
310
+ root, "X",
311
+ shape=(item.emap.n_rows, item.emap.target_cols[0]),
312
+ data_dtype=value_dtype(item.header, scanned),
313
+ compression=detail.compression,
314
+ )
315
+ with console.status(f"[cyan]Reading {_label(item)}...[/]"):
316
+ summed = read_into(item.header, item.emap, scanned, [[appender]], root)
317
+ appender.finish()
318
+ if summed:
319
+ console.print(
320
+ f"[yellow]Warning:[/] {summed:,} duplicate entries in "
321
+ f"{_label(item)} were summed, as scipy does."
322
+ )
323
+
324
+
325
+ def _label(item: _Item) -> str:
326
+ return f"{item.dge.sample}/{item.dge.path.name}/{item.dge.matrix.name}"
327
+
328
+
329
+ def _plan_item(dge: _Dge, output: Path, options: Options):
330
+ """Everything about one DGE folder, checked before any output exists."""
331
+ header = read_header(dge.matrix)
332
+ if header.field not in ("integer", "real", "pattern"):
333
+ raise ValueError(f"{dge.matrix.name} holds {header.field} values, which AnnData cannot.")
334
+
335
+ columns, rows = read_small_table(dge.genes)
336
+ for name in ("gene_id", "gene_name"):
337
+ if name not in columns:
338
+ raise ValueError(
339
+ f"{dge.genes.name} has no {name!r} column; it has: {', '.join(columns)}."
340
+ )
341
+ if len(rows) != header.n_cols:
342
+ raise ValueError(
343
+ f"{dge.genes.name} lists {len(rows):,} genes, but {dge.matrix.name} "
344
+ f"has {header.n_cols:,} columns."
345
+ )
346
+ field = {name: np.array([row[i] for row in rows], dtype=object) for i, name in enumerate(columns)}
347
+ names = field["gene_name"]
348
+ var = {"gene_ids": field["gene_id"]}
349
+ var.update({k: v for k, v in field.items() if k not in ("gene_id", "gene_name")})
350
+
351
+ select = None
352
+ keep = np.ones(header.n_cols, dtype=bool)
353
+ if options.genome is not None:
354
+ genomes = field.get("genome")
355
+ if genomes is None:
356
+ raise ValueError(f"{dge.genes.name} records no genome per gene to filter on.")
357
+ keep = genomes == options.genome
358
+ if not keep.any():
359
+ available = list(dict.fromkeys(genomes.tolist()))
360
+ raise ValueError(
361
+ f"No genes of genome {options.genome!r}; this run has: {', '.join(available)}."
362
+ )
363
+ select = ("species", options.genome)
364
+
365
+ cells = scan_table(dge.cells, index=_INDEX, select=select)
366
+ if cells.n_rows != header.n_rows:
367
+ raise ValueError(
368
+ f"{dge.cells.name} lists {cells.n_rows:,} cells, but {dge.matrix.name} "
369
+ f"has {header.n_rows:,} rows."
370
+ )
371
+ picked = None
372
+ if select is not None:
373
+ picked = cells.selected
374
+ if picked.size == 0:
375
+ raise ValueError(
376
+ f"No cell in {dge.cells.name} has species {options.genome!r}."
377
+ )
378
+
379
+ _, (target,), notes = plan_targets(
380
+ output, names, var, None, keep, options, noun="run"
381
+ )
382
+ row_map = None
383
+ if picked is not None:
384
+ row_map = np.full(header.n_rows, -1, dtype=np.int64)
385
+ row_map[picked] = np.arange(picked.size)
386
+ emap = _entry_map(
387
+ picked.size if picked is not None else header.n_rows, header.n_cols, target, row_map
388
+ )
389
+ return _Item(dge, header, cells, picked, target, emap), notes
390
+
391
+
392
+ def _entry_map(
393
+ n_rows: int, n_genes: int, target: Target, row_map: Optional[np.ndarray]
394
+ ) -> EntryMap:
395
+ if target.keep is None:
396
+ return EntryMap(n_rows=n_rows, n_cols=n_genes, row_map=row_map)
397
+ owner = np.full(n_genes, -1, dtype=np.int64)
398
+ local = np.zeros(n_genes, dtype=np.int64)
399
+ owner[target.keep] = 0
400
+ local[target.keep] = np.arange(target.keep.size)
401
+ return EntryMap(
402
+ n_rows=n_rows, n_cols=n_genes, owner=owner, local=local,
403
+ target_cols=[int(target.keep.size)], row_map=row_map,
404
+ )
@@ -127,6 +127,22 @@ def write_categorical(
127
127
  return group
128
128
 
129
129
 
130
+ def create_categorical(
131
+ parent: Any, name: str, categories: Sequence[Any], n: int, ordered: bool = False
132
+ ) -> Any:
133
+ """A `categorical` of `n` values whose codes the caller fills in blocks.
134
+
135
+ Returns the `codes` array, sized and typed for `categories`.
136
+ """
137
+ group = parent.create_group(name)
138
+ spec.set_encoding(group, spec.CATEGORICAL)
139
+ group.attrs["ordered"] = bool(ordered)
140
+ write_string_array(group, "categories", np.asarray(list(categories), dtype=object))
141
+ codes = create_dataset(group, "codes", shape=(n,), dtype=_codes_dtype(len(categories)))
142
+ spec.set_encoding(codes, spec.ARRAY)
143
+ return codes
144
+
145
+
130
146
  def _codes_dtype(n_categories: int) -> Any:
131
147
  """Smallest signed integer dtype that can index `n_categories` plus -1."""
132
148
  if n_categories < 128:
@@ -216,7 +216,9 @@ class EntryMap:
216
216
  feature (the output's column axis), split the features among several
217
217
  outputs: `owner` gives each feature's output, or -1 to drop it, and
218
218
  `local` its column there. Outputs are numbered within one key space,
219
- `key = output * n_rows + row`, so one sort orders them all.
219
+ `key = output * n_rows + row`, so one sort orders them all. `row_map`,
220
+ indexed by the file's row (after `transpose`), gives each its output row,
221
+ or -1 to drop it; `n_rows` then counts output rows.
220
222
  """
221
223
 
222
224
  n_rows: int
@@ -226,6 +228,7 @@ class EntryMap:
226
228
  local: Optional[np.ndarray] = None
227
229
  n_targets: int = 1
228
230
  target_cols: List[int] = field(default_factory=list)
231
+ row_map: Optional[np.ndarray] = None
229
232
 
230
233
  def __post_init__(self) -> None:
231
234
  if not self.target_cols:
@@ -235,6 +238,10 @@ class EntryMap:
235
238
  self, rows: np.ndarray, cols: np.ndarray, values: np.ndarray
236
239
  ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
237
240
  out_rows, features = (cols, rows) if self.transpose else (rows, cols)
241
+ if self.row_map is not None:
242
+ out_rows = self.row_map[out_rows]
243
+ kept = out_rows >= 0
244
+ out_rows, features, values = out_rows[kept], features[kept], values[kept]
238
245
  if self.owner is None:
239
246
  return out_rows, features, values
240
247
  owner = self.owner[features]
@@ -0,0 +1,364 @@
1
+ """Delimited text tables, read in two streaming passes.
2
+
3
+ Per-cell metadata arrives as CSV (Parse's `cell_metadata.csv`) with a row
4
+ per cell, so a mega run's table has a million rows. `pd.read_csv` would hold
5
+ it whole; here the file is read a block of lines at a time, twice.
6
+
7
+ The first pass finds each column's type as `pd.read_csv` would: `int64`,
8
+ `float64` when a number is missing or fractional, `bool` for a column of
9
+ True/False, and text otherwise, with pandas' own list of strings that mean
10
+ "missing". The second writes the columns as anndata would write that pandas
11
+ frame: a text column becomes a categorical when it repeats a value
12
+ (anndata's `strings_to_categoricals`), and a string array when every value
13
+ is distinct.
14
+
15
+ Two limits, both deliberate:
16
+
17
+ * A text column with more than `CATEGORY_CAP` distinct values is written as
18
+ strings, even when some repeat, since tracking its values would hold the
19
+ column in memory.
20
+ * A quoted field may not contain a newline: the file is cut into blocks at
21
+ line ends. Such a row is refused with its line number.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ import io
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, Dict, Iterator, List, Optional, Set, Tuple
31
+
32
+ import numpy as np
33
+
34
+ from adata.elements import spec
35
+ from adata.elements.strings import string_dtype_for
36
+ from adata.elements.write import create_categorical, set_column_order, write_string_array
37
+ from adata.formats.textio import iter_line_blocks, open_binary
38
+ from adata.storage import create_dataset, is_zarr_group
39
+ from adata.util.names import natural_sorted
40
+
41
+ #: Distinct values tracked per text column before it is left as strings.
42
+ CATEGORY_CAP = 1 << 16
43
+
44
+ #: Bytes of a table parsed at once. Smaller than for MTX, because a parsed
45
+ #: row is a list of Python strings, several times the size of its text.
46
+ TABLE_BLOCK_BYTES = 1 << 20
47
+
48
+ #: What `pd.read_csv` reads as missing, by default.
49
+ NA_VALUES = frozenset({
50
+ "", "#N/A", "#N/A N/A", "#NA", "-1.#IND", "-1.#QNAN", "-NaN", "-nan",
51
+ "1.#IND", "1.#QNAN", "<NA>", "N/A", "NA", "NULL", "NaN", "None", "n/a",
52
+ "nan", "null",
53
+ })
54
+
55
+ _TRUE = frozenset({"True", "TRUE", "true"})
56
+ _FALSE = frozenset({"False", "FALSE", "false"})
57
+
58
+ # A column's kind narrows as values are seen: bool -> int -> float -> text.
59
+ _ORDER = ("bool", "int", "float", "text")
60
+
61
+
62
+ @dataclass
63
+ class Column:
64
+ name: str
65
+ kind: str = "bool"
66
+ has_missing: bool = False
67
+ #: Distinct values of a text column, None once past the cap.
68
+ seen: Optional[Set[str]] = field(default_factory=set)
69
+
70
+ @property
71
+ def categories(self) -> Optional[List[str]]:
72
+ """The categories, in the order anndata gives them."""
73
+ return None if self.seen is None else natural_sorted(self.seen)
74
+
75
+
76
+ @dataclass
77
+ class TableScan:
78
+ path: Path
79
+ delimiter: str
80
+ header: List[str]
81
+ index: str
82
+ n_rows: int
83
+ columns: List[Column]
84
+ #: Rows (0-based, after the header) where `select` matched, if asked.
85
+ selected: Optional[np.ndarray] = None
86
+
87
+ def dtype_of(self, column: Column) -> str:
88
+ """What the column is written as: int64, float64, bool, category or str."""
89
+ if column.kind == "int":
90
+ return "float64" if column.has_missing else "int64"
91
+ if column.kind == "float":
92
+ return "float64"
93
+ if column.kind == "bool":
94
+ if not column.seen:
95
+ return "float64" # nothing but missing values, as pandas reads it
96
+ # pandas reads True/False with gaps as text.
97
+ return "category" if column.has_missing else "bool"
98
+ return "category" if self.is_categorical(column) else "str"
99
+
100
+ def is_categorical(self, column: Column) -> bool:
101
+ if column.kind not in ("text", "bool") or column.seen is None:
102
+ return False
103
+ # anndata's rule: fewer categories (missing values are none) than
104
+ # rows. So a column of distinct values and one gap is categorical.
105
+ return len(column.seen) < self.n_rows
106
+
107
+
108
+ def _rows(
109
+ path: Path, delimiter: str, block_bytes: int
110
+ ) -> Iterator[Tuple[int, List[List[str]]]]:
111
+ """Blocks of parsed rows, each with the file line number of its first."""
112
+ line = 1
113
+ with open_binary(path) as fh:
114
+ for block in iter_line_blocks(fh, block_bytes):
115
+ reader = csv.reader(io.StringIO(block.decode("utf-8")), delimiter=delimiter, strict=True)
116
+ rows: List[List[str]] = []
117
+ start = line
118
+ try:
119
+ for row in reader:
120
+ rows.append(row)
121
+ except csv.Error as exc:
122
+ raise ValueError(
123
+ f"{path.name}, line {start + reader.line_num - 1}: {exc}. "
124
+ "A quoted field may not span lines."
125
+ ) from exc
126
+ line += block.count(b"\n")
127
+ yield start, rows
128
+
129
+
130
+ def _narrow(column: Column, values: List[str]) -> None:
131
+ """Narrow `column`'s kind to cover `values`."""
132
+ present = [v for v in values if v not in NA_VALUES]
133
+ if len(present) < len(values):
134
+ column.has_missing = True
135
+ kind = _ORDER.index(column.kind)
136
+ while present and kind < 3:
137
+ try:
138
+ if kind == 0:
139
+ if not all(v in _TRUE or v in _FALSE for v in present):
140
+ raise ValueError
141
+ elif kind == 1:
142
+ np.array(present, dtype=np.int64)
143
+ else:
144
+ np.array(present, dtype=np.float64)
145
+ break
146
+ except (ValueError, OverflowError):
147
+ kind += 1
148
+ column.kind = _ORDER[kind]
149
+ if column.seen is not None:
150
+ column.seen.update(present)
151
+ if len(column.seen) > CATEGORY_CAP:
152
+ column.seen = None
153
+
154
+
155
+ def scan_table(
156
+ path: Path,
157
+ *,
158
+ index: Optional[str] = None,
159
+ delimiter: str = ",",
160
+ select: Optional[Tuple[str, str]] = None,
161
+ block_bytes: int = TABLE_BLOCK_BYTES,
162
+ ) -> TableScan:
163
+ """Pass 1: the header, the row count and each column's type.
164
+
165
+ `index` names the column that becomes the row names (the first, if not
166
+ given). `select=(column, value)` also records the rows where that column
167
+ holds that value.
168
+ """
169
+ path = Path(path)
170
+ header: Optional[List[str]] = None
171
+ columns: List[Column] = []
172
+ positions: List[int] = []
173
+ pick = -1
174
+ selected: List[np.ndarray] = []
175
+ n_rows = 0
176
+ for start, rows in _rows(path, delimiter, block_bytes):
177
+ if header is None:
178
+ rows = [row for row in rows if row]
179
+ if not rows:
180
+ continue
181
+ header, rows = rows[0], rows[1:]
182
+ if len(set(header)) != len(header):
183
+ repeated = sorted({h for h in header if header.count(h) > 1})
184
+ raise ValueError(f"{path.name} repeats the column {', '.join(map(repr, repeated))}.")
185
+ index = header[0] if index is None else index
186
+ if index not in header:
187
+ raise ValueError(f"{path.name} has no {index!r} column; it has: {', '.join(header)}.")
188
+ positions = [i for i, name in enumerate(header) if name != index]
189
+ columns = [Column(header[i]) for i in positions]
190
+ if select is not None:
191
+ if select[0] not in header:
192
+ raise ValueError(f"{path.name} has no {select[0]!r} column.")
193
+ pick = header.index(select[0])
194
+ start += 1
195
+ _check_widths(path, rows, len(header), start)
196
+ rows = [row for row in rows if row]
197
+ if not rows:
198
+ continue
199
+ by_field = list(zip(*rows))
200
+ for column, i in zip(columns, positions):
201
+ _narrow(column, list(by_field[i]))
202
+ if pick >= 0:
203
+ hits = np.flatnonzero(np.asarray(by_field[pick], dtype=object) == select[1])
204
+ selected.append(hits + n_rows)
205
+ n_rows += len(rows)
206
+ if header is None:
207
+ raise ValueError(f"{path.name} is empty: it has no header line.")
208
+ return TableScan(
209
+ path=path,
210
+ delimiter=delimiter,
211
+ header=header,
212
+ index=index,
213
+ n_rows=n_rows,
214
+ columns=columns,
215
+ selected=(
216
+ np.concatenate(selected) if selected else np.zeros(0, dtype=np.int64)
217
+ ) if select is not None else None,
218
+ )
219
+
220
+
221
+ def _check_widths(path: Path, rows: List[List[str]], width: int, start: int) -> None:
222
+ for offset, row in enumerate(rows):
223
+ if row and len(row) != width:
224
+ raise ValueError(
225
+ f"{path.name}, line {start + offset}: {len(row)} fields where "
226
+ f"the header has {width}."
227
+ )
228
+
229
+
230
+ def iter_table_blocks(
231
+ scan: TableScan,
232
+ *,
233
+ rows: Optional[np.ndarray] = None,
234
+ block_bytes: int = TABLE_BLOCK_BYTES,
235
+ ) -> Iterator[Tuple[List[str], Dict[str, List[str]]]]:
236
+ """Pass 2: (index values, {column: values}) a block of rows at a time.
237
+
238
+ `rows`, ascending, keeps only those rows.
239
+ """
240
+ at = scan.header.index(scan.index)
241
+ done = 0
242
+ first = True
243
+ for _, block in _rows(scan.path, scan.delimiter, block_bytes):
244
+ block = [row for row in block if row]
245
+ if first and block:
246
+ block, first = block[1:], False
247
+ if not block:
248
+ continue
249
+ if rows is not None:
250
+ lo, hi = np.searchsorted(rows, (done, done + len(block)))
251
+ picked = [block[i] for i in rows[lo:hi] - done]
252
+ done += len(block)
253
+ block = picked
254
+ if not block:
255
+ continue
256
+ else:
257
+ done += len(block)
258
+ by_field = list(zip(*block))
259
+ yield list(by_field[at]), {
260
+ name: list(by_field[i]) for i, name in enumerate(scan.header) if name != scan.index
261
+ }
262
+
263
+
264
+ def _values(scan: TableScan, column: Column, values: List[str], lookup: Dict[str, int]) -> np.ndarray:
265
+ kind = scan.dtype_of(column)
266
+ if kind == "int64":
267
+ return np.array(values, dtype=np.int64)
268
+ if kind == "float64":
269
+ return np.array([np.nan if v in NA_VALUES else v for v in values], dtype=np.float64)
270
+ if kind == "bool":
271
+ return np.array([v in _TRUE for v in values], dtype=bool)
272
+ if kind == "category":
273
+ return np.array([lookup.get(v, -1) for v in values], dtype=np.int64)
274
+ return np.array(["" if v in NA_VALUES else v for v in values], dtype=object)
275
+
276
+
277
+ def write_table(
278
+ parent: Any,
279
+ name: str,
280
+ scan: TableScan,
281
+ *,
282
+ rows: Optional[np.ndarray] = None,
283
+ block_bytes: int = TABLE_BLOCK_BYTES,
284
+ ) -> Any:
285
+ """Pass 2: the table as a `dataframe` named `name` under `parent`.
286
+
287
+ Every array is created at its full length first and filled a block of
288
+ rows at a time, so memory is set by the block, not by the table.
289
+ """
290
+ n = scan.n_rows if rows is None else int(rows.size)
291
+ backend = "zarr" if is_zarr_group(parent) else "hdf5"
292
+ group = parent.create_group(name)
293
+ spec.set_encoding(group, spec.DATAFRAME)
294
+ group.attrs["_index"] = "_index"
295
+ set_column_order(group, [c.name for c in scan.columns])
296
+
297
+ index = create_dataset(group, "_index", shape=(n,), dtype=string_dtype_for(backend))
298
+ spec.set_encoding(index, spec.STRING_ARRAY)
299
+ targets: Dict[str, Any] = {}
300
+ lookups: Dict[str, Dict[str, int]] = {}
301
+ for column in scan.columns:
302
+ kind = scan.dtype_of(column)
303
+ if kind == "category":
304
+ categories = column.categories or []
305
+ targets[column.name] = create_categorical(group, column.name, categories, n)
306
+ lookups[column.name] = {c: i for i, c in enumerate(categories)}
307
+ continue
308
+ dtype = string_dtype_for(backend) if kind == "str" else np.dtype(kind)
309
+ targets[column.name] = create_dataset(group, column.name, shape=(n,), dtype=dtype)
310
+ spec.set_encoding(
311
+ targets[column.name], spec.STRING_ARRAY if kind == "str" else spec.ARRAY
312
+ )
313
+
314
+ used = {name: np.zeros(len(lookup), dtype=bool) for name, lookup in lookups.items()}
315
+ written = 0
316
+ for names, block in iter_table_blocks(scan, rows=rows, block_bytes=block_bytes):
317
+ end = written + len(names)
318
+ index[written:end] = np.asarray(names, dtype=object)
319
+ for column in scan.columns:
320
+ values = _values(scan, column, block[column.name], lookups.get(column.name, {}))
321
+ targets[column.name][written:end] = values
322
+ if column.name in used:
323
+ used[column.name][values[values >= 0]] = True
324
+ written = end
325
+ if written != n:
326
+ raise ValueError(f"{scan.path.name} changed while it was read: {written:,} rows, not {n:,}.")
327
+ if rows is not None:
328
+ for name, seen in used.items():
329
+ if not seen.all():
330
+ _drop_unused(group, name, seen, n, block_rows=max(1, block_bytes // 8))
331
+ return group
332
+
333
+
334
+ def _drop_unused(group: Any, name: str, used: np.ndarray, n: int, block_rows: int) -> None:
335
+ """Remove categories no written row uses, as anndata does on subsetting."""
336
+ remap = np.full(used.size + 1, -1, dtype=np.int64) # the last slot maps -1
337
+ remap[:-1][used] = np.arange(int(used.sum()))
338
+ categorical = group[name]
339
+ categories = np.asarray(categorical["categories"][...], dtype=object)[used]
340
+ del categorical["categories"]
341
+ write_string_array(categorical, "categories", categories)
342
+ codes = categorical["codes"]
343
+ for lo in range(0, n, block_rows):
344
+ block = np.asarray(codes[lo : lo + block_rows], dtype=np.int64)
345
+ codes[lo : lo + block.size] = remap[block]
346
+
347
+
348
+ def read_small_table(path: Path, delimiter: str = ",") -> Tuple[List[str], List[List[str]]]:
349
+ """The header and every row of a table small enough to hold, such as a
350
+ gene list."""
351
+ header: Optional[List[str]] = None
352
+ body: List[List[str]] = []
353
+ for start, rows in _rows(Path(path), delimiter, TABLE_BLOCK_BYTES):
354
+ if header is None:
355
+ rows = [row for row in rows if row]
356
+ if not rows:
357
+ continue
358
+ header, rows = rows[0], rows[1:]
359
+ start += 1
360
+ _check_widths(Path(path), rows, len(header), start)
361
+ body.extend(row for row in rows if row)
362
+ if header is None:
363
+ raise ValueError(f"{Path(path).name} is empty: it has no header line.")
364
+ return header, body
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  from collections import Counter
7
- from typing import Dict, List, Sequence
7
+ from typing import Dict, Iterable, List, Sequence
8
8
 
9
9
 
10
10
  def sanitize_label(label: str) -> str:
@@ -67,3 +67,20 @@ def make_index_unique(names: Sequence[str], join: str = "-") -> List[str]:
67
67
  values[i] = candidate
68
68
  break
69
69
  return values
70
+
71
+
72
+ _DIGITS = re.compile(r"(\d+)")
73
+
74
+
75
+ def natural_key(text: str) -> tuple:
76
+ """Sort key putting "A2" before "A10", as natsort's default does."""
77
+ parts = _DIGITS.split(text)
78
+ return tuple(int(part) if i % 2 else part for i, part in enumerate(parts))
79
+
80
+
81
+ def natural_sorted(values: Iterable[str]) -> List[str]:
82
+ """`values` in natsort's default order, which is how anndata orders the
83
+ categories it makes from strings (`strings_to_categoricals`). Equal keys
84
+ ("01" and "1") keep their plain sorted order, as natsort's stable sort
85
+ of pandas' sorted categories does."""
86
+ return sorted(sorted(values), key=natural_key)
File without changes