Skip to content

Commit 6cdfd47

Browse files
authored
Merge pull request #34 from cellgeni/feat/convert-10x-h5
convert: read and write 10x Genomics HDF5 matrices
2 parents a446038 + f34e8ff commit 6cdfd47

20 files changed

Lines changed: 2161 additions & 64 deletions

‎.github/workflows/tests.yml‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,44 @@ jobs:
127127
if ran < 150:
128128
sys.exit(f"expected at least 150 cases to run, got {ran}")
129129
EOF
130+
131+
# scanpy and PyTables read the 10x files `convert` writes, and scanpy is
132+
# the reference for what it reads. Each runs in its own uv environment.
133+
external-readers:
134+
runs-on: ubuntu-latest
135+
timeout-minutes: 20
136+
name: 10x h5 against scanpy and PyTables
137+
138+
permissions:
139+
contents: read
140+
141+
steps:
142+
- uses: actions/checkout@v4
143+
144+
- uses: astral-sh/setup-uv@v3
145+
with:
146+
enable-cache: true
147+
148+
- name: Install dependencies (frozen)
149+
run: uv sync --extra dev --frozen
150+
151+
- name: Run external reader tests
152+
env:
153+
# An environment that will not build must fail the job, not skip.
154+
ADATA_REQUIRE_VERSION_FIXTURES: "1"
155+
run: |
156+
uv run pytest -v -W default tests/test_external_readers.py \
157+
-m integration --junitxml=external-results.xml
158+
159+
- name: Confirm the cases actually ran
160+
if: always()
161+
run: |
162+
python - <<'PY'
163+
import sys, xml.etree.ElementTree as ET
164+
root = ET.parse("external-results.xml").getroot()
165+
suite = root.find("testsuite") if root.tag == "testsuites" else root
166+
ran = int(suite.get("tests", 0)) - int(suite.get("skipped", 0))
167+
print(f"{ran} external reader cases ran")
168+
if ran < 8:
169+
sys.exit(f"expected at least 8 cases to run, got {ran}")
170+
PY

‎CHANGELOG.md‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,25 @@ Notable changes to `adata-cli`. Versions are `MAJOR.MINOR.PATCH`; tags carry no
2323
conversion asked for is refused rather than ignored.
2424

2525
Formats are registered in `adata.conversion`, each with the converters
26-
that read and write it; 10x HDF5, MTX folders, loom and others follow as
27-
separate issues under #20.
26+
that read and write it; MTX folders, loom and others follow as separate
27+
issues under #20.
28+
29+
- **`convert` reads and writes 10x Genomics HDF5 matrices**
30+
([#23](https://github.com/cellgeni/adata-cli/issues/23)): Cell Ranger's
31+
`*_feature_bc_matrix.h5`, v2 and v3+, and the same layout from Space
32+
Ranger, Xenium and CellBender. Reading gives what
33+
`scanpy.read_10x_h5(gex_only=False)` gives, column for column -- checked
34+
against scanpy itself in CI -- except that `X` keeps the integer counts
35+
and the run's metadata is kept in `uns["cellranger"]`. `--feature-type`
36+
keeps one type, `--split-feature-types` writes one output per type from a
37+
single pass over the matrix, `--genome` picks a genome, and `--var-names`
38+
and `--make-unique` do what scanpy's options of the same name do.
39+
40+
Writing produces the Cell Ranger 3+ layout from X, `raw/X` or any layer
41+
(`--layer`), for CellBender, SoupX, DropletUtils and Seurat: gzip and
42+
fixed-width strings, which PyTables -- and so CellBender -- needs. A file
43+
imported by `convert` exports with its metadata intact, and the round
44+
trip reproduces Cell Ranger's file dataset for dataset.
2845

2946
### Changed
3047

@@ -43,6 +60,13 @@ Notable changes to `adata-cli`. Versions are `MAJOR.MINOR.PATCH`; tags carry no
4360

4461
### Fixed
4562

63+
- **`recast --layout` left its h5ad output up to three times too large.**
64+
The streaming transpose kept its intermediate buckets in the output and
65+
deleted them when done, but HDF5 never returns a deleted dataset's space
66+
to the file. On a 20,000 x 2,000 matrix at 5% the output was 55 MB where
67+
the in-memory transpose wrote 17 MB. The buckets now go in a separate
68+
file beside the output, removed afterwards; Zarr outputs were unaffected.
69+
4670
- **Writing an h5ad with a `None` in it to Zarr failed.** anndata stores
4771
`None` as a null, which on HDF5 is an empty dataspace with no value to
4872
read, and copying one raised `TypeError: float() argument must be a string

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A command-line tool for exploring huge AnnData stores (`.h5ad` and `.zarr`) with
88
- Auto-detects `.h5ad` files vs `.zarr` directories
99
- Chunked processing for dense and sparse matrices (CSR/CSC)
1010
- Reads every AnnData on-disk layout, from 0.7.x through the current spec, and always writes the current one
11-
- Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`
11+
- Converts between HDF5 and Zarr (v2 and v3) in either direction with `adata convert`, and to and from 10x Genomics HDF5 matrices
1212
- Rich terminal output with progress indicators, kept on stderr so results pipe cleanly
1313

1414
**Documentation: [cellgeni.github.io/adata-cli](https://cellgeni.github.io/adata-cli/)**
@@ -56,7 +56,7 @@ Run help at any level (e.g. `adata --help`, `adata export --help`).
5656
- `subset` – stream and write a filtered copy, selected by obs/var name lists (`--obs`/`--var`) or by expression (`--obs-query`/`--var-query`).
5757
- `split` – write one store per distinct value of an annotation column, with a CSV manifest.
5858
- `concat` – concatenate stores along the obs axis, with `--join inner|outer` and merge strategies for var and uns.
59-
- `convert` – change a store's format: h5ad ↔ Zarr v2/v3, streaming, with the output written beside its destination and moved into place only once complete.
59+
- `convert` – change a store's format: h5ad ↔ Zarr v2/v3, and 10x Genomics HDF5 (Cell Ranger's `*_feature_bc_matrix.h5`) in and out, streaming, with the output written beside its destination and moved into place only once complete.
6060
- `recast` – change a matrix's dtype, layout (CSR/CSC/dense) or density, streaming; refuses a lossy cast or a large size increase unless forced. Called `convert` until 0.7.0.
6161
- `export` – extract data from a store; subcommands: `dataframe` (any dataframe group to CSV), `array` (dense to `.npy`), `sparse` (CSR/CSC to `.mtx`), `dict` (JSON), `image` (PNG). Results go to stdout when no `--output` is given.
6262
- `import` – write new data into a store at any path; subcommands: `dataframe` (CSV), `array` (`.npy`), `sparse` (`.mtx`), `dict` (JSON), `image` (PNG/JPEG/TIFF).
@@ -98,6 +98,8 @@ how you make them agree.
9898
```bash
9999
adata convert data.h5ad -o data.zarr
100100
adata convert data.zarr -o data.h5ad --compression gzip
101+
adata convert filtered_feature_bc_matrix.h5 -o counts.h5ad --feature-type "Gene Expression"
102+
adata convert counts.h5ad -o for_cellbender.h5 --layer counts
101103
```
102104

103105
## Documentation

‎docs/COMMANDS.md‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ adata convert data.h5ad -o data.zarr
9494
adata convert data.zarr -o data.h5ad --compression gzip
9595
adata convert old.zarr -o new.zarr --zarr-format 3
9696
adata convert data.h5ad -o data.zarr --overwrite
97+
adata convert filtered_feature_bc_matrix.h5 -o counts.h5ad
98+
adata convert counts.h5ad -o for_cellbender.h5 --layer counts
9799
```
98100

99101
| Flag | Meaning |
@@ -104,12 +106,16 @@ adata convert data.h5ad -o data.zarr --overwrite
104106
| `--zarr-format` | Zarr version to write; defaults to the source's, else 3 |
105107
| `--compression` | `none`, `gzip` or `lzf` for h5ad; `none`, `gzip`, `zstd` or `blosc` for Zarr |
106108
| `--overwrite` | Replace an existing output |
107-
| `--force` | Convert despite a lossy or inflating result |
109+
| `--force` | Convert despite a lossy, inflating or placeholder result |
108110

109111
| Format | Extension | Read | Write |
110112
|---|---|---|---|
111113
| `h5ad` | `.h5ad` | yes | yes |
112114
| `zarr` | `.zarr` (v2 or v3) | yes | yes |
115+
| `10x-h5` | `.h5` | Cell Ranger 2 and 3+, Space Ranger, Xenium | Cell Ranger 3+ layout |
116+
117+
Formats other than h5ad and Zarr convert to and from AnnData only: to turn a
118+
10x file into another format, convert it to `.h5ad` first.
113119

114120
Between h5ad and Zarr every element is copied as it is, streamed, so the
115121
store's size is not a limit. Chunking carries over, and so does compression
@@ -128,6 +134,58 @@ successor is finished.
128134
To change a matrix's dtype, layout or density instead, use
129135
[`recast`](#recast).
130136

137+
### 10x Genomics HDF5
138+
139+
Cell Ranger's `filtered_feature_bc_matrix.h5` and `raw_feature_bc_matrix.h5`,
140+
and the same layout from Space Ranger, Xenium and CellBender. The file is
141+
recognised by its content, whatever its name.
142+
143+
```bash
144+
adata convert filtered_feature_bc_matrix.h5 -o counts.h5ad
145+
adata convert raw_feature_bc_matrix.h5 -o rna.h5ad --feature-type "Gene Expression"
146+
adata convert multiome.h5 -o sample.h5ad --split-feature-types
147+
adata convert cellranger2.h5 -o mouse.h5ad --genome mm10
148+
adata convert counts.h5ad -o out.h5 --layer counts
149+
```
150+
151+
| Flag | Meaning |
152+
|---|---|
153+
| `--genome` | Keep this genome's features; picks the group in a Cell Ranger 2 file holding several |
154+
| `--var-names` | `gene_symbols` (default) or `gene_ids` |
155+
| `--make-unique` | Suffix repeated var names as `var_names_make_unique` does |
156+
| `--feature-type` | Keep one feature type, e.g. `"Gene Expression"` |
157+
| `--split-feature-types` | Write one output per type, `OUT.<type>.h5ad` |
158+
| `--layer` | Export: the matrix to write, `X` (default), `raw/X` or a layer |
159+
| `--gene-ids-column` | Export: var column with gene ids (default `gene_ids`) |
160+
| `--gene-names-column` | Export: var column with gene names (default the var names) |
161+
| `--feature-types-column` | Export: var column with feature types (default `feature_types`) |
162+
163+
Reading gives what `scanpy.read_10x_h5(gex_only=False)` gives, column for
164+
column: `gene_ids`, `feature_types`, every other per-feature field Cell Ranger
165+
recorded (`genome`, and `pattern`, `read` and so on for antibody and CRISPR
166+
features), `probe_ids` for a probe-based (Flex) matrix, and
167+
`filtered_barcodes` in obs where present. Unlike scanpy, every feature type
168+
is kept unless `--feature-type` says otherwise, and `X` keeps Cell Ranger's
169+
integer counts, where scanpy converts them to float32; run `adata recast
170+
out.h5ad X --inplace --dtype float32` if a tool needs floats. Repeated var
171+
names are kept and reported, as scanpy does. The run's metadata, which scanpy
172+
drops, goes in `uns["cellranger"]`.
173+
174+
The matrix is stored genes x cells in CSC, which is byte for byte cells x
175+
genes in CSR, so `data`, `indices` and `indptr` stream straight across;
176+
`--split-feature-types` reads them once for every output.
177+
178+
Writing produces the Cell Ranger 3+ layout for CellBender, SoupX,
179+
DropletUtils, Seurat and anything else that reads Cell Ranger output: gzip
180+
compression and fixed-width strings, as Cell Ranger writes them, since
181+
PyTables (which CellBender reads with) cannot read the variable-length kind.
182+
A CSC or dense matrix is transposed or sparsified on the way, streaming.
183+
Each feature needs an id and a type: if var has no `gene_ids` and no
184+
`feature_types` column (or the columns named by the flags above), the export
185+
is refused, and `--force` writes the names as ids and `Gene Expression` as
186+
the type. A matrix of floats is written as it is, with a warning. A file
187+
imported by `convert` exports with its run metadata and tag fields intact.
188+
131189
## `recast`
132190

133191
Change a matrix's dtype, layout or density. Counts held as float64 cost twice

‎docs/TESTING.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ uv sync --extra dev
55
uv run pytest # everything
66
uv run pytest -m "not integration" # fast: no environment building, ~40s
77
uv run pytest -m perf # just the tracing complexity guards
8-
uv run pytest -m integration # compatibility across anndata releases
8+
uv run pytest -m integration # anndata releases, scanpy and PyTables
99
```
1010

1111
CI runs the unit suite on Python 3.12 and 3.13 with a **90% coverage floor**,
12-
and the compatibility suite as a separate job.
12+
and the anndata compatibility suite and the 10x external-reader suite as
13+
separate jobs.
1314

1415
## How the suite is organised
1516

@@ -25,6 +26,8 @@ and the compatibility suite as a separate job.
2526
| `test_commands_phase2.py`, `test_commands_coverage.py`, `test_cli.py` | Command surfaces and error paths |
2627
| `test_convert.py` | `convert` between every backend pair, read back by anndata; its refusals, staging, and the forwarding of recast's old flags |
2728
| `test_recast.py` | `recast` against scipy: casts, transposes, density changes and every refusal |
29+
| `test_tenx.py` | 10x HDF5 import and export against Cell Ranger-shaped fixtures (v2, v3, multimodal, probe-based): mapping, filters, round trips, refusals |
30+
| `test_external_readers.py` | 10x conversions checked by scanpy and PyTables in their own uv environments (`integration`) |
2831
| `test_performance.py` | Complexity guards -- what an operation costs, not how long it takes (see below) |
2932
| `test_benchmark_harness.py` | That the benchmark's measurement is trustworthy |
3033
| `test_subset.py`, `test_export.py`, `test_import.py`, `test_info_read.py`, `test_zarr.py`, `test_query.py` | Per-feature unit tests |
@@ -235,6 +238,7 @@ should scale with, scale only that, and hold everything else fixed.
235238
| `export array`, `sparse` (both paths), `dict`, `image` | elements, nnz, keys, pixels | linear |
236239
| `import dataframe`, `array`, `sparse`, `dict`, `image` | rows, elements, nnz, keys, pixels | linear in writes |
237240
| h5ad to zarr (`subset`), `convert` both directions | n_obs | linear |
241+
| `convert` 10x import (whole, `--split-feature-types`), export | n_barcodes | linear |
238242
| `recast --dtype`, `--layout csc`, `--layout dense` | nnz, n_obs | linear; the streaming transpose's **peak memory** is bounded |
239243

240244
The `slow` marker is on the three guards that build a 65,536-row store; they still gate

‎docs/index.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ adata split data.h5ad --by sample -o per_sample/
5252
adata concat per_sample/*.h5ad -o merged.h5ad --join outer --label sample
5353

5454
adata convert data.h5ad -o data.zarr
55+
adata convert filtered_feature_bc_matrix.h5 -o counts.h5ad
5556
adata recast data.h5ad X -o small.h5ad --dtype float32
5657

5758
adata create new.h5ad --n-obs 5000 --n-var 2000
@@ -66,6 +67,8 @@ CI against stores written by anndata 0.8, 0.9, 0.10, 0.11, 0.12 and 0.13, in
6667
both formats. Both backends are supported in both directions: `adata convert
6768
in.h5ad -o out.zarr` converts a whole store, and `subset`, `split` and
6869
`concat` convert as they write when the output's backend differs.
70+
`convert` also reads and writes 10x Genomics HDF5 matrices (Cell Ranger's
71+
`*_feature_bc_matrix.h5`), matching what `scanpy.read_10x_h5` reads.
6972

7073
| Element | Read | Write |
7174
|---|---|---|

‎src/adata/cli.py‎

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,27 @@ def concat(
503503
"compression": "--compression",
504504
"force": "--force",
505505
"overwrite": "--overwrite",
506+
"genome": "--genome",
507+
"var_names": "--var-names",
508+
"make_unique": "--make-unique",
509+
"feature_type": "--feature-type",
510+
"split_feature_types": "--split-feature-types",
511+
"layer": "--layer",
512+
"gene_ids_column": "--gene-ids-column",
513+
"gene_names_column": "--gene-names-column",
514+
"feature_types_column": "--feature-types-column",
515+
}
516+
517+
#: Flags that only make sense for format conversion; with recast's flags,
518+
#: they mean two commands' worth of request at once.
519+
_FORMAT_ONLY_FLAGS = {
520+
"from_format": "--from",
521+
"to_format": "--to",
522+
**{
523+
name: flag
524+
for name, flag in _CONVERT_FLAGS.items()
525+
if flag not in ("--force", "--zarr-format")
526+
},
506527
}
507528

508529

@@ -532,7 +553,7 @@ def convert(
532553
None,
533554
"--output",
534555
"-o",
535-
help="Output path; its extension picks the format (.h5ad, .zarr)",
556+
help="Output path; its extension picks the format (.h5ad, .zarr, .h5)",
536557
dir_okay=True,
537558
file_okay=True,
538559
),
@@ -567,6 +588,51 @@ def convert(
567588
"--overwrite",
568589
help="Replace an existing output",
569590
),
591+
genome: Optional[str] = typer.Option(
592+
None,
593+
"--genome",
594+
help="10x input: the genome to keep (picks the group in a Cell Ranger 2 file)",
595+
),
596+
var_names: str = typer.Option(
597+
"gene_symbols",
598+
"--var-names",
599+
help="10x input: name features by gene_symbols or gene_ids",
600+
),
601+
make_unique: bool = typer.Option(
602+
False,
603+
"--make-unique",
604+
help="10x input: suffix repeated var names, as var_names_make_unique does",
605+
),
606+
feature_type: Optional[str] = typer.Option(
607+
None,
608+
"--feature-type",
609+
help="10x input: keep only this feature type, e.g. 'Gene Expression'",
610+
),
611+
split_feature_types: bool = typer.Option(
612+
False,
613+
"--split-feature-types",
614+
help="10x input: write one output per feature type, OUT.<type>.h5ad",
615+
),
616+
layer: Optional[str] = typer.Option(
617+
None,
618+
"--layer",
619+
help="10x output: the matrix to write: X (default), raw/X, or a layer",
620+
),
621+
gene_ids_column: Optional[str] = typer.Option(
622+
None,
623+
"--gene-ids-column",
624+
help="10x output: var column holding gene ids (default gene_ids)",
625+
),
626+
gene_names_column: Optional[str] = typer.Option(
627+
None,
628+
"--gene-names-column",
629+
help="10x output: var column holding gene names (default: var_names)",
630+
),
631+
feature_types_column: Optional[str] = typer.Option(
632+
None,
633+
"--feature-types-column",
634+
help="10x output: var column holding feature types (default feature_types)",
635+
),
570636
# Deprecated: recast's flags, so `convert` keeps working as it did until
571637
# 1.0.0. Hidden from --help; the old matrix paths arrive in ctx.args.
572638
inplace: bool = typer.Option(False, "--inplace", hidden=True),
@@ -580,9 +646,12 @@ def convert(
580646
"""
581647
Convert a store to another format.
582648
583-
The output's extension picks the format: .h5ad or .zarr. Converting
584-
between them copies every element, streamed, keeping the source's
585-
chunking and compression where the target can express them.
649+
The output's extension picks the format: .h5ad, .zarr, or .h5 for a 10x
650+
Genomics matrix (Cell Ranger's *_feature_bc_matrix.h5). Between h5ad and
651+
zarr every element is copied, streamed, keeping the source's chunking and
652+
compression where the target can express them. A 10x matrix is read as
653+
scanpy.read_10x_h5(gex_only=False) reads it, keeping integer counts, and
654+
written as Cell Ranger writes it.
586655
587656
Nothing is written until every check has passed, and the output is moved
588657
into place only once complete: a failed conversion leaves nothing
@@ -595,21 +664,16 @@ def convert(
595664
adata convert data.h5ad -o data.zarr
596665
adata convert data.zarr -o data.h5ad --compression gzip
597666
adata convert data.zarr -o v3.zarr --zarr-format 3
667+
adata convert filtered_feature_bc_matrix.h5 -o counts.h5ad
668+
adata convert raw_feature_bc_matrix.h5 -o rna.h5ad --feature-type "Gene Expression"
669+
adata convert counts.h5ad -o for_cellbender.h5 --layer counts
598670
"""
599671
entries = list(ctx.args)
600672
recast_flags = _given(ctx, _RECAST_FLAGS)
601673

602674
try:
603675
if recast_flags:
604-
mixed = _given(
605-
ctx,
606-
{
607-
"from_format": "--from",
608-
"to_format": "--to",
609-
"compression": "--compression",
610-
"overwrite": "--overwrite",
611-
},
612-
)
676+
mixed = _given(ctx, _FORMAT_ONLY_FLAGS)
613677
if mixed:
614678
raise ValueError(
615679
f"{', '.join(mixed)} cannot be combined with "
@@ -677,6 +741,15 @@ def convert(
677741
compression=compression,
678742
force=force,
679743
overwrite=overwrite,
744+
genome=genome,
745+
var_names=var_names,
746+
make_unique=make_unique,
747+
feature_type=feature_type,
748+
split_feature_types=split_feature_types,
749+
layer=layer,
750+
gene_ids_column=gene_ids_column,
751+
gene_names_column=gene_names_column,
752+
feature_types_column=feature_types_column,
680753
),
681754
)
682755
except Exception as e:

0 commit comments

Comments
 (0)