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.
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/PKG-INFO +2 -1
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/README.md +1 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/pyproject.toml +1 -1
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/cli.py +33 -6
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/__init__.py +9 -1
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/base.py +6 -1
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/features.py +9 -3
- pyadata_cli-0.10.0/src/adata/conversion/parse.py +404 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/write.py +16 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/mtx.py +8 -1
- pyadata_cli-0.10.0/src/adata/formats/table.py +364 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/util/names.py +18 -1
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/LICENSE +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/__init__.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/__init__.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/concat.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/convert.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/create.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/export.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/import_data.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/info.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/ls.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/recast.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/split.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/commands/subset.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/anndata_copy.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/cellbender.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/mtx.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/conversion/tenx.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/__init__.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/concat.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/csr_build.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/info.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/query.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/read.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/recast.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/select.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/core/subset.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/__init__.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/read.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/spec.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/elements/strings.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/__init__.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/array.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/common.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/dataframe.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/image.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/json_data.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/sparse.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/textio.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/formats/validate.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/info.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/py.typed +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/read.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/storage/__init__.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/util/__init__.py +0 -0
- {pyadata_cli-0.9.0 → pyadata_cli-0.10.0}/src/adata/util/path.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
"
|
|
614
|
+
"gene_ids",
|
|
612
615
|
"--var-names",
|
|
613
|
-
help="10x input: name features by
|
|
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
|
|
684
|
-
read_10x_mtx (gex_only=False) reads it, keeping integer
|
|
685
|
-
written as Cell Ranger writes it. MTX folders from Cell
|
|
686
|
-
STARsolo, velocyto's included,
|
|
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 = "
|
|
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
|
|
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
|
|
226
|
+
categories = natural_sorted(set(values.tolist()))
|
|
222
227
|
if len(categories) < len(values):
|
|
223
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|