pyadata-cli 0.5.0__tar.gz → 0.6.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 (43) hide show
  1. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/PKG-INFO +19 -4
  2. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/README.md +18 -3
  3. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/pyproject.toml +1 -1
  4. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/cli.py +142 -5
  5. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/__init__.py +2 -1
  6. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/concat.py +2 -2
  7. pyadata_cli-0.6.0/src/adata/commands/convert.py +218 -0
  8. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/core/concat.py +63 -26
  9. pyadata_cli-0.6.0/src/adata/core/convert.py +906 -0
  10. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/core/select.py +22 -2
  11. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/storage/__init__.py +122 -5
  12. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/LICENSE +0 -0
  13. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/__init__.py +0 -0
  14. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/create.py +0 -0
  15. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/export.py +0 -0
  16. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/import_data.py +0 -0
  17. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/info.py +0 -0
  18. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/ls.py +0 -0
  19. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/split.py +0 -0
  20. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/commands/subset.py +0 -0
  21. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/core/__init__.py +0 -0
  22. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/core/info.py +0 -0
  23. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/core/query.py +0 -0
  24. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/core/read.py +0 -0
  25. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/core/subset.py +0 -0
  26. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/elements/__init__.py +0 -0
  27. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/elements/read.py +0 -0
  28. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/elements/spec.py +0 -0
  29. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/elements/strings.py +0 -0
  30. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/elements/write.py +0 -0
  31. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/__init__.py +0 -0
  32. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/array.py +0 -0
  33. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/common.py +0 -0
  34. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/dataframe.py +0 -0
  35. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/image.py +0 -0
  36. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/json_data.py +0 -0
  37. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/sparse.py +0 -0
  38. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/formats/validate.py +0 -0
  39. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/info.py +0 -0
  40. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/py.typed +0 -0
  41. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/read.py +0 -0
  42. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/util/__init__.py +0 -0
  43. {pyadata_cli-0.5.0 → pyadata_cli-0.6.0}/src/adata/util/path.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyadata-cli
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -44,7 +44,7 @@ A command-line tool for exploring huge AnnData stores (`.h5ad` and `.zarr`) with
44
44
 
45
45
  ## Features
46
46
 
47
- - Streaming access to very large `.h5ad` and `.zarr` stores
47
+ - Streaming access to very large `.h5ad` and `.zarr` stores — see [the benchmarks](docs/BENCHMARKS.md) for what that costs in practice, including where loading the file outright is faster
48
48
  - Auto-detects `.h5ad` files vs `.zarr` directories
49
49
  - Chunked processing for dense and sparse matrices (CSR/CSC)
50
50
  - Reads every AnnData on-disk layout, from 0.7.x through the current spec, and always writes the current one
@@ -96,6 +96,7 @@ Run help at any level (e.g. `adata --help`, `adata export --help`).
96
96
  - `subset` – stream and write a filtered copy, selected by obs/var name lists (`--obs`/`--var`) or by expression (`--obs-query`/`--var-query`).
97
97
  - `split` – write one store per distinct value of an annotation column, with a CSV manifest.
98
98
  - `concat` – concatenate stores along the obs axis, with `--join inner|outer` and merge strategies for var and uns.
99
+ - `convert` – change a matrix's dtype, layout (CSR/CSC/dense) or density, streaming; refuses a lossy cast or a large size increase unless forced.
99
100
  - `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.
100
101
  - `import` – write new data into a store at any path; subcommands: `dataframe` (CSV), `array` (`.npy`), `sparse` (`.mtx`), `dict` (JSON), `image` (PNG/JPEG/TIFF).
101
102
 
@@ -118,12 +119,26 @@ adata split data.h5ad --by sample -o per_sample/
118
119
  adata concat per_sample/*.h5ad -o merged.h5ad --join outer --label sample
119
120
  ```
120
121
 
122
+ ### Shrinking a store, and making encodings agree
123
+
124
+ ```bash
125
+ adata convert data.h5ad X --inplace --dtype float32
126
+ adata convert data.h5ad --all -o small.h5ad --dtype float32 --indices-dtype int32
127
+ adata convert data.h5ad X -o csc.h5ad --layout csc
128
+ ```
129
+
130
+ Counts written as float64 halve with no loss — and `convert` proves that
131
+ before it writes, by casting every value and casting it back. `concat`
132
+ refuses inputs whose matrices disagree about CSR versus CSC; `--layout` is
133
+ how you make them agree.
134
+
121
135
  ## Documentation
122
136
 
123
137
  - [Get started](docs/GET_STARTED.md) — a short tutorial
124
138
  - [Command reference](docs/COMMANDS.md) — every command and flag
125
139
  - [Element spec: HDF5](docs/ELEMENTS_h5ad.md) / [Zarr](docs/ELEMENTS_zarr.md) — the on-disk format, and what this tool does with it
126
- - [Testing](docs/TESTING.md) — how the suite is organised, and how compatibility is verified against six anndata releases
140
+ - [Testing](docs/TESTING.md) — how the suite is organised, how compatibility is verified against six anndata releases, and the complexity guards that keep cost regressions out
141
+ - [Benchmarks](docs/BENCHMARKS.md) — peak memory and wall time against anndata and scanpy, remeasured on every release
127
142
  - [Changelog](CHANGELOG.md)
128
143
 
129
144
  ## Docker
@@ -131,5 +146,5 @@ adata concat per_sample/*.h5ad -o merged.h5ad --join outer --label sample
131
146
  A docker image is available on QUAY: `quay.io/cellgeni/adata-cli:latest`. Pull and run with:
132
147
 
133
148
  ```bash
134
- docker run --rm -it -v /path/to/data:/data quay.io/cellgeni/adata-cli:latest view /data/your_file.h5ad
149
+ docker run --rm -it -v /path/to/data:/data quay.io/cellgeni/adata-cli:latest adata view /data/your_file.h5ad
135
150
  ```
@@ -4,7 +4,7 @@ A command-line tool for exploring huge AnnData stores (`.h5ad` and `.zarr`) with
4
4
 
5
5
  ## Features
6
6
 
7
- - Streaming access to very large `.h5ad` and `.zarr` stores
7
+ - Streaming access to very large `.h5ad` and `.zarr` stores — see [the benchmarks](docs/BENCHMARKS.md) for what that costs in practice, including where loading the file outright is faster
8
8
  - Auto-detects `.h5ad` files vs `.zarr` directories
9
9
  - Chunked processing for dense and sparse matrices (CSR/CSC)
10
10
  - Reads every AnnData on-disk layout, from 0.7.x through the current spec, and always writes the current one
@@ -56,6 +56,7 @@ Run help at any level (e.g. `adata --help`, `adata export --help`).
56
56
  - `subset` – stream and write a filtered copy, selected by obs/var name lists (`--obs`/`--var`) or by expression (`--obs-query`/`--var-query`).
57
57
  - `split` – write one store per distinct value of an annotation column, with a CSV manifest.
58
58
  - `concat` – concatenate stores along the obs axis, with `--join inner|outer` and merge strategies for var and uns.
59
+ - `convert` – change a matrix's dtype, layout (CSR/CSC/dense) or density, streaming; refuses a lossy cast or a large size increase unless forced.
59
60
  - `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.
60
61
  - `import` – write new data into a store at any path; subcommands: `dataframe` (CSV), `array` (`.npy`), `sparse` (`.mtx`), `dict` (JSON), `image` (PNG/JPEG/TIFF).
61
62
 
@@ -78,12 +79,26 @@ adata split data.h5ad --by sample -o per_sample/
78
79
  adata concat per_sample/*.h5ad -o merged.h5ad --join outer --label sample
79
80
  ```
80
81
 
82
+ ### Shrinking a store, and making encodings agree
83
+
84
+ ```bash
85
+ adata convert data.h5ad X --inplace --dtype float32
86
+ adata convert data.h5ad --all -o small.h5ad --dtype float32 --indices-dtype int32
87
+ adata convert data.h5ad X -o csc.h5ad --layout csc
88
+ ```
89
+
90
+ Counts written as float64 halve with no loss — and `convert` proves that
91
+ before it writes, by casting every value and casting it back. `concat`
92
+ refuses inputs whose matrices disagree about CSR versus CSC; `--layout` is
93
+ how you make them agree.
94
+
81
95
  ## Documentation
82
96
 
83
97
  - [Get started](docs/GET_STARTED.md) — a short tutorial
84
98
  - [Command reference](docs/COMMANDS.md) — every command and flag
85
99
  - [Element spec: HDF5](docs/ELEMENTS_h5ad.md) / [Zarr](docs/ELEMENTS_zarr.md) — the on-disk format, and what this tool does with it
86
- - [Testing](docs/TESTING.md) — how the suite is organised, and how compatibility is verified against six anndata releases
100
+ - [Testing](docs/TESTING.md) — how the suite is organised, how compatibility is verified against six anndata releases, and the complexity guards that keep cost regressions out
101
+ - [Benchmarks](docs/BENCHMARKS.md) — peak memory and wall time against anndata and scanpy, remeasured on every release
87
102
  - [Changelog](CHANGELOG.md)
88
103
 
89
104
  ## Docker
@@ -91,5 +106,5 @@ adata concat per_sample/*.h5ad -o merged.h5ad --join outer --label sample
91
106
  A docker image is available on QUAY: `quay.io/cellgeni/adata-cli:latest`. Pull and run with:
92
107
 
93
108
  ```bash
94
- docker run --rm -it -v /path/to/data:/data quay.io/cellgeni/adata-cli:latest view /data/your_file.h5ad
109
+ docker run --rm -it -v /path/to/data:/data quay.io/cellgeni/adata-cli:latest adata view /data/your_file.h5ad
95
110
  ```
@@ -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.5.0"
6
+ version = "0.6.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"
@@ -6,9 +6,11 @@ from typing import Optional, Sequence, List
6
6
  from rich.console import Console
7
7
  import typer
8
8
 
9
+ from adata.core.convert import LAYOUTS
9
10
  from adata.commands import (
10
- MERGE_STRATEGIES,
11
+ MERGE_CHOICES,
11
12
  concat_stores,
13
+ convert_store,
12
14
  create_store,
13
15
  split_store,
14
16
  list_store,
@@ -413,12 +415,14 @@ def concat(
413
415
  merge: Optional[str] = typer.Option(
414
416
  None,
415
417
  "--merge",
416
- help="How to reconcile var columns: same, unique, first, only (default: drop)",
418
+ help="How to reconcile var columns: drop, same, unique, first, only "
419
+ "(default: drop)",
417
420
  ),
418
421
  uns_merge: Optional[str] = typer.Option(
419
422
  None,
420
423
  "--uns-merge",
421
- help="How to reconcile uns: same, unique, first, only (default: drop)",
424
+ help="How to reconcile uns: drop, same, unique, first, only "
425
+ "(default: drop)",
422
426
  ),
423
427
  fill_value: float = typer.Option(
424
428
  0.0, "--fill-value", help="Value for dense cells introduced by an outer join"
@@ -442,13 +446,18 @@ def concat(
442
446
  adata concat a.h5ad b.h5ad -o m.h5ad --keys a,b --index-unique - --uns-merge same
443
447
  """
444
448
  for name, value in (("--merge", merge), ("--uns-merge", uns_merge)):
445
- if value is not None and value not in MERGE_STRATEGIES:
449
+ if value is not None and value not in MERGE_CHOICES:
446
450
  console.print(
447
451
  f"[bold red]Error:[/] {name} must be one of: "
448
- f"{', '.join(MERGE_STRATEGIES)}"
452
+ f"{', '.join(MERGE_CHOICES)}"
449
453
  )
450
454
  raise typer.Exit(code=1)
451
455
 
456
+ # "drop" is the default, and naming it explicitly has to be allowed: a
457
+ # config that spells out the default should not be rejected.
458
+ merge = None if merge == "drop" else merge
459
+ uns_merge = None if uns_merge == "drop" else uns_merge
460
+
452
461
  try:
453
462
  concat_stores(
454
463
  files,
@@ -469,6 +478,134 @@ def concat(
469
478
  raise typer.Exit(code=1)
470
479
 
471
480
 
481
+ # ============================================================================
482
+ # CONVERT command
483
+ # ============================================================================
484
+ @app.command("convert")
485
+ def convert(
486
+ file: Path = typer.Argument(
487
+ ...,
488
+ help="Input .h5ad/.zarr",
489
+ exists=True,
490
+ readable=True,
491
+ dir_okay=True,
492
+ file_okay=True,
493
+ ),
494
+ entries: Optional[List[str]] = typer.Argument(
495
+ None,
496
+ help="Matrix paths to convert, e.g. 'X', 'layers/counts', 'raw/X'",
497
+ ),
498
+ output: Optional[Path] = typer.Option(
499
+ None,
500
+ "--output",
501
+ "-o",
502
+ help="Output .h5ad/.zarr path. Required unless --inplace.",
503
+ dir_okay=True,
504
+ file_okay=True,
505
+ ),
506
+ inplace: bool = typer.Option(
507
+ False,
508
+ "--inplace",
509
+ help="Modify source file directly.",
510
+ ),
511
+ convert_all: bool = typer.Option(
512
+ False,
513
+ "--all",
514
+ help="Convert X, every layer, and raw/X",
515
+ ),
516
+ dtype: Optional[str] = typer.Option(
517
+ None,
518
+ "--dtype",
519
+ help="New dtype for the values, e.g. float32",
520
+ ),
521
+ indices_dtype: Optional[str] = typer.Option(
522
+ None,
523
+ "--indices-dtype",
524
+ help="New dtype for sparse indices: int32 or int64",
525
+ ),
526
+ layout: Optional[str] = typer.Option(
527
+ None,
528
+ "--layout",
529
+ help="Target layout: csr, csc, dense or sparse",
530
+ ),
531
+ force: bool = typer.Option(
532
+ False,
533
+ "--force",
534
+ help="Convert despite a lossy cast or a large size increase",
535
+ ),
536
+ in_memory: bool = typer.Option(
537
+ False,
538
+ "--in-memory",
539
+ help="Transpose in memory instead of streaming (faster if it fits)",
540
+ ),
541
+ chunk_rows: int = typer.Option(
542
+ 1024,
543
+ "--chunk",
544
+ "-C",
545
+ help="Row chunk size for dense matrices",
546
+ ),
547
+ zarr_format: Optional[int] = typer.Option(
548
+ None,
549
+ "--zarr-format",
550
+ help="Zarr spec version to write (defaults to the source store's)",
551
+ ),
552
+ ) -> None:
553
+ """
554
+ Change a matrix's dtype, layout or density.
555
+
556
+ Counts held as float64 cost twice the disk and twice the read for no
557
+ information; a tool that wants CSC cannot use a CSR store; and concat
558
+ refuses inputs whose encodings disagree. All three are this command.
559
+
560
+ A cast that would not round-trip is refused before anything is written,
561
+ as is a densification that would inflate the store; --force overrides
562
+ both. Transposing streams by default, so it works on matrices too large
563
+ to load.
564
+
565
+ Examples:
566
+ adata convert data.h5ad X -o out.h5ad --dtype float32
567
+ adata convert data.h5ad X -o out.h5ad --layout csc
568
+ adata convert data.h5ad X --inplace --dtype float32 --indices-dtype int32
569
+ adata convert data.h5ad --all -o out.h5ad --dtype float32
570
+ """
571
+ if not inplace and output is None:
572
+ console.print(
573
+ "[bold red]Error:[/] Output file is required. "
574
+ "Use --output/-o or --inplace.",
575
+ )
576
+ raise typer.Exit(code=1)
577
+
578
+ if layout is not None and layout not in LAYOUTS:
579
+ console.print(
580
+ f"[bold red]Error:[/] --layout must be one of: {', '.join(LAYOUTS)}."
581
+ )
582
+ raise typer.Exit(code=1)
583
+
584
+ if zarr_format is not None and zarr_format not in (2, 3):
585
+ console.print("[bold red]Error:[/] --zarr-format must be 2 or 3.")
586
+ raise typer.Exit(code=1)
587
+
588
+ try:
589
+ convert_store(
590
+ file=file,
591
+ entries=list(entries) if entries else None,
592
+ output=output,
593
+ console=console,
594
+ dtype=dtype,
595
+ indices_dtype=indices_dtype,
596
+ layout=layout,
597
+ convert_all=convert_all,
598
+ inplace=inplace,
599
+ chunk_rows=chunk_rows,
600
+ in_memory=in_memory,
601
+ force=force,
602
+ zarr_format=zarr_format,
603
+ )
604
+ except Exception as e:
605
+ console.print(f"[bold red]Error:[/] {e}")
606
+ raise typer.Exit(code=1)
607
+
608
+
472
609
  # ============================================================================
473
610
  # SPLIT command
474
611
  # ============================================================================
@@ -5,4 +5,5 @@ from adata.commands.import_data import import_object
5
5
  from adata.commands.ls import list_store
6
6
  from adata.commands.create import create_store
7
7
  from adata.commands.split import split_store
8
- from adata.commands.concat import MERGE_STRATEGIES, concat_stores
8
+ from adata.commands.concat import MERGE_CHOICES, MERGE_STRATEGIES, concat_stores
9
+ from adata.commands.convert import convert_store
@@ -7,9 +7,9 @@ from typing import Optional, Sequence
7
7
 
8
8
  from rich.console import Console
9
9
 
10
- from adata.core.concat import MERGE_STRATEGIES, concat_on_disk
10
+ from adata.core.concat import MERGE_CHOICES, MERGE_STRATEGIES, concat_on_disk
11
11
 
12
- __all__ = ["MERGE_STRATEGIES", "concat_stores"]
12
+ __all__ = ["MERGE_CHOICES", "MERGE_STRATEGIES", "concat_stores"]
13
13
 
14
14
 
15
15
  def concat_stores(
@@ -0,0 +1,218 @@
1
+ """The `convert` command: rewrite matrices, copy everything else.
2
+
3
+ Writes a whole new store rather than editing one in place, for the same
4
+ reason `subset` does: a conversion that fails half way leaves the original
5
+ untouched, and `--inplace` becomes an atomic swap of a finished file rather
6
+ than a partial edit of a live one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import Any, List, Optional
14
+
15
+ from rich.console import Console
16
+
17
+ from adata.core.convert import (
18
+ DEFAULT_CHUNK,
19
+ LAYOUTS,
20
+ Plan,
21
+ convert_matrix,
22
+ parse_dtype,
23
+ plan_conversion,
24
+ )
25
+ from adata.elements import spec
26
+ from adata.elements.write import ensure_anndata_skeleton
27
+ from adata.storage import copy_tree, detect_backend, is_group, open_store
28
+
29
+ #: What `--all` means. The matrices an AnnData object is built from, not
30
+ #: every 2-D array in the file: obsm/varm hold embeddings, whose dtype is
31
+ #: rarely what anyone is trying to shrink. Those still convert by path.
32
+ ALL_PREFIXES = ("X", "layers", "raw/X")
33
+
34
+
35
+ def discover_matrices(root: Any) -> List[str]:
36
+ """Entry paths `--all` resolves to, in a stable order."""
37
+ found: List[str] = []
38
+ if "X" in root:
39
+ found.append("X")
40
+ if "layers" in root and is_group(root["layers"]):
41
+ found.extend(f"layers/{key}" for key in sorted(root["layers"].keys()))
42
+ if "raw" in root and is_group(root["raw"]) and "X" in root["raw"]:
43
+ found.append("raw/X")
44
+ return found
45
+
46
+
47
+ def _same_path(left: Path, right: Path) -> bool:
48
+ """Do these name the same store, through symlinks and `..` alike?"""
49
+ try:
50
+ return left.resolve() == right.resolve()
51
+ except OSError: # pragma: no cover - unresolvable path
52
+ return left.absolute() == right.absolute()
53
+
54
+
55
+ def _resolve(root: Any, path: str) -> Any:
56
+ obj = root
57
+ for part in path.split("/"):
58
+ if part not in obj:
59
+ raise KeyError(f"{path!r} not found in the store.")
60
+ obj = obj[part]
61
+ return obj
62
+
63
+
64
+ def convert_store(
65
+ file: Path,
66
+ entries: Optional[List[str]],
67
+ output: Optional[Path],
68
+ console: Console,
69
+ *,
70
+ dtype: Optional[str] = None,
71
+ indices_dtype: Optional[str] = None,
72
+ layout: Optional[str] = None,
73
+ convert_all: bool = False,
74
+ inplace: bool = False,
75
+ chunk: int = DEFAULT_CHUNK,
76
+ chunk_rows: int = 1024,
77
+ in_memory: bool = False,
78
+ force: bool = False,
79
+ zarr_format: Optional[int] = None,
80
+ ) -> None:
81
+ """Write a copy of `file` with the named matrices converted."""
82
+ if not inplace and output is None:
83
+ raise ValueError("Output file is required unless --inplace is specified.")
84
+ if dtype is None and indices_dtype is None and layout is None:
85
+ raise ValueError(
86
+ "Nothing to do: pass at least one of --dtype, --indices-dtype "
87
+ "or --layout."
88
+ )
89
+ if layout is not None and layout not in LAYOUTS:
90
+ raise ValueError(f"--layout must be one of: {', '.join(LAYOUTS)}.")
91
+
92
+ data_dtype = parse_dtype(dtype) if dtype else None
93
+ index_dtype = (
94
+ parse_dtype(indices_dtype, allowed=("int32", "int64"))
95
+ if indices_dtype
96
+ else None
97
+ )
98
+
99
+ if output is not None and not inplace and _same_path(file, output):
100
+ # Opening the destination "w" clears it while the source is still
101
+ # being read from it. HDF5 refuses; Zarr does not, and quietly
102
+ # produced an empty store where the data used to be.
103
+ raise ValueError(
104
+ f"Output path is the input: {output}. Use --inplace to replace "
105
+ "it, which writes to a temporary file first."
106
+ )
107
+
108
+ if inplace:
109
+ backend = detect_backend(file)
110
+ if backend == "zarr":
111
+ base = file.stem if file.suffix else file.name
112
+ dst_path = file.with_name(f"{base}.convert-tmp.zarr")
113
+ else:
114
+ dst_path = file.with_name(f"{file.name}.convert-tmp")
115
+ if dst_path.exists():
116
+ raise FileExistsError(f"Temporary path already exists: {dst_path}")
117
+ else:
118
+ dst_path = output
119
+
120
+ if zarr_format is None and detect_backend(file) == "zarr":
121
+ with open_store(file, "r") as probe:
122
+ zarr_format = probe.zarr_format
123
+
124
+ with open_store(file, "r") as src_store:
125
+ src = src_store.root
126
+ targets = discover_matrices(src) if convert_all else list(entries or [])
127
+ if not targets:
128
+ raise ValueError(
129
+ "No matrices selected. Name one (e.g. `X`) or pass --all."
130
+ )
131
+ # Every check first, before the destination exists. A refusal must
132
+ # not leave behind a store holding a copy of obs and var and nothing
133
+ # else -- the caller cannot tell that from a finished conversion.
134
+ plans = {
135
+ path: plan_conversion(
136
+ _resolve(src, path),
137
+ path,
138
+ dtype=data_dtype,
139
+ index_dtype=index_dtype,
140
+ layout=layout,
141
+ chunk=chunk,
142
+ force=force,
143
+ console=console,
144
+ )
145
+ for path in targets
146
+ }
147
+
148
+ console.print(
149
+ f"[cyan]Converting {len(targets)} matrix/matrices:[/] "
150
+ + ", ".join(targets)
151
+ )
152
+
153
+ with open_store(dst_path, "w", zarr_format=zarr_format) as dst_store:
154
+ dst = dst_store.root
155
+ _write(
156
+ src, dst, plans,
157
+ chunk=chunk, chunk_rows=chunk_rows, in_memory=in_memory,
158
+ console=console,
159
+ )
160
+ ensure_anndata_skeleton(dst)
161
+
162
+ if inplace:
163
+ if file.is_dir():
164
+ shutil.rmtree(file)
165
+ elif file.exists():
166
+ file.unlink()
167
+ if dst_path.is_dir():
168
+ shutil.move(str(dst_path), str(file))
169
+ else:
170
+ dst_path.replace(file)
171
+ console.print(f"[green]Converted[/] {file}")
172
+ else:
173
+ console.print(f"[green]Wrote[/] {dst_path}")
174
+
175
+
176
+ def _write(src: Any, dst: Any, plans: dict, **options: Any) -> None:
177
+ """Copy the store across, converting the targeted entries as they pass."""
178
+ by_parent: dict = {}
179
+ for path in plans:
180
+ parent, _, leaf = path.rpartition("/")
181
+ by_parent.setdefault(parent, {})[leaf] = plans[path]
182
+
183
+ for key in src.keys():
184
+ if key in by_parent.get("", {}):
185
+ convert_matrix(
186
+ src[key], dst, key, plan=by_parent[""][key], **options
187
+ )
188
+ elif key in by_parent:
189
+ # Any parent, not just layers and raw. Restricting it to those
190
+ # two meant an explicitly named `obsm/X_pca` was copied
191
+ # unconverted and the command still reported success.
192
+ _write_group(src[key], dst, key, by_parent, **options)
193
+ else:
194
+ copy_tree(src[key], dst, key)
195
+
196
+
197
+ def _write_group(
198
+ group: Any, dst: Any, name: str, by_parent: dict, **options: Any
199
+ ) -> None:
200
+ """Recreate one container, converting the members that were named."""
201
+ from adata.storage import copy_attrs, is_zarr_group
202
+
203
+ out = dst.create_group(name)
204
+ copy_attrs(
205
+ group.attrs,
206
+ out.attrs,
207
+ target_backend="zarr" if is_zarr_group(dst) else "hdf5",
208
+ )
209
+ if not spec.encoding_type(out):
210
+ spec.set_encoding(out, spec.RAW if name == "raw" else spec.DICT)
211
+
212
+
213
+ wanted = by_parent.get(name, {})
214
+ for key in group.keys():
215
+ if key in wanted:
216
+ convert_matrix(group[key], out, key, plan=wanted[key], **options)
217
+ else:
218
+ copy_tree(group[key], out, key)