Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# AGENTS.md

Guidance for coding agents working in this repository. `CLAUDE.md` imports this
file. Edit this one, not that.

`adata` is a streaming CLI for AnnData stores (`.h5ad` and `.zarr`). It reads
and writes the on-disk format with h5py and zarr directly. It does not depend on
anndata: anndata appears only in tests and benchmarks, as the reference. The
point of the tool is that memory is set by `--chunk`, not by the size of the
input, so code that loads a whole matrix or column is a regression unless it is
behind an explicit `--in-memory`.
Comment on lines +9 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify the whole-column memory rule

For workflows involving axis metadata, this invariant does not describe the repository: core/select.py::read_names reads an entire axis index, core/concat.py::concat_on_disk retains every input's obs and var names, and conversion/tenx.py::_features calls read_str_all for complete var columns. Their memory therefore scales with axis length even without --in-memory, so an agent cannot follow this guidance without treating established implementations as regressions. Either distinguish matrix payloads from metadata or stream these paths before presenting the rule as universal.

AGENTS.md reference: AGENTS.md:L6-L11

Useful? React with 👍 / 👎.


Naming:
- The PyPI distribution is `pyadata-cli`.
- The import package and the command are `adata`.
- The repo directory is still called `h5ad-cli`.
- `h5ad` is a deprecated alias of the command (`main_deprecated` in `cli.py`).
- `convert` given recast's old flags forwards to `recast` with a warning.

Both of these deprecated paths stay until 1.0.0.

## Commands

```bash
uv sync --extra dev # set up
uv run adata --help # run the CLI from source
uv run pytest -m "not integration" # unit suite, ~2.5 min; what CI gates on
uv run pytest tests/test_recast.py::test_inplace_replaces_the_source # one test
uv run pytest -m "not integration" --cov=adata --cov-fail-under=90 # CI's coverage floor
uv run pytest -m perf # tracing complexity guards only
ADATA_REQUIRE_VERSION_FIXTURES=1 uv run pytest tests/test_anndata_versions.py -m integration
uv run python -m benchmarks.run --tier smoke --case concat-inner --out results.json
uv run python -m benchmarks.report results.json
```

Test markers:
- `integration` tests build uv environments with real anndata releases (0.8 to
0.13), scanpy and PyTables, so they need the network on the first run.
- `perf` tests are slow, but CI runs them in the main job.

Neither CI nor the repo runs a linter or formatter. Match the style of the
surrounding code.

## Architecture

Each layer calls only the layers below it:

- **`cli.py`**: the whole Typer app, including the `export` and `import` sub-apps.
- Commands parse options, validate cheap things such as `--zarr-format`, call
into `commands/`, and turn exceptions into `Error:` plus exit code 1.
- `console` writes to **stderr**. Only `out_console` and data go to stdout,
so output pipes cleanly.
- **`commands/`**: one module per command. Each orchestrates its command:
- planning and refusals;
- progress output;
- choosing the output Zarr version;
- staging outputs.
- **`core/`**: the streaming algorithms:
- `subset.py` holds a fan-out writer shared by `subset` and `split`, which
reads each matrix once for every output;
- `concat.py` concatenates on disk;
- `recast.py` handles dtype, layout and density changes, including an
out-of-core transpose;
- `query.py` and `select.py` hold the expression language behind
`--obs-query`.
- **`elements/`**: the AnnData on-disk spec.
- `spec.py` is the single source of truth for `encoding-type` and
`encoding-version`.
- `read.py` accepts every layout from anndata 0.7 to the current one.
- `write.py` always writes the current one.
- `strings.py` reconciles how HDF5 and Zarr spell text.
- The full spec is in `docs/ELEMENTS_h5ad.md` and `docs/ELEMENTS_zarr.md`.
- **`storage/`**: hides whether a store is h5py or zarr.
- `open_store(path, mode, zarr_format=...)` detects the backend from the
path and returns a `Store`.
- On close, a writable Zarr `Store` rewrites the consolidated metadata.
Without that, anndata reads a stale index and new members look missing.
- Use the `is_group`/`is_dataset`/`create_dataset`/`copy_tree` helpers here
rather than branching on the backend yourself.
- **`conversion/`**: `adata convert`. AnnData is the hub, so each format has an
importer, an exporter, or both, and there are no converters between pairs of
other formats. To add a format:
- add one `Format` entry in `conversion/__init__.py::FORMATS` with a content
sniffer;
- write a converter module (`tenx.py` is the model);
- add Cell Ranger-style h5py fixtures in `tests/conftest.py`;
- add a scanpy comparison in `tests/test_external_readers.py`.

Content sniffing runs before extensions, because extensions lie (`.h5`).
- **`formats/`**: the per-file-type halves of `export` and `import`: npy, mtx,
csv, json and images.
- **`util/`**: `staging.py` and `path.py` (below), plus `names.py`.

## Rules the code depends on

These are easy to break without any test noticing straight away:

- **Never write a store in place.** Every command that writes a store wraps the
write in `util/staging.staged_outputs([...])`.
Comment on lines +98 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit the staged-write guarantee to commands that implement it

When an agent works on create or any import subcommand, this categorical guarantee is false: commands/create.py::create_store deletes a forced destination and opens the final path directly with mode "w", while commands/import_data.py::_prepare_target_path copies or opens the final output directly and --inplace later mutates it with mode "a". A failure can therefore leave those destinations removed, partially written, or modified, despite this guide implying atomic rollback; either stage those flows or name only the commands for which the rule currently holds.

AGENTS.md reference: AGENTS.md:L98-L106

Useful? React with 👍 / 👎.

- It writes to `out.adata-tmp.<ext>` and renames into place only when the
write succeeds.
- It moves an existing output aside to `*.adata-old.*` and deletes it only
once every new output is in place.
- It refuses to start if leftovers from an interrupted run exist.
- `--inplace` passes the source itself as the output. Open the source
*inside* the staging block, so it is closed before it is renamed aside.
- **Refuse an output that names the input** with `util/path.same_path`. With
Zarr, opening the output `"w"` clears the directory that is still being read.
- **Validate before the output exists.** Resolve selections, check casts and
build plans first, so that a refusal writes nothing.
- **Zarr outputs keep the source's version.**
- Pass `zarr_format=` to `open_store`. Without it zarr-python writes v3, and a
v2 input gets silently upgraded.
- Commands read the source's version from `Store.zarr_format`.
- `concat` uses the version its Zarr inputs share.
- **Outputs keep the source's index dtypes, chunking and compression.**
Changing them is `recast`'s job, or that of an explicit flag.
Comment on lines +116 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the required exceptions to layout preservation

For concatenations whose combined dimensions or nonzero count exceed a source integer type, core/concat.py::_concat_index_dtypes intentionally widens indices or indptr; similarly, storage.fit_chunks must shrink invalid HDF5 chunks and cross-backend copies cannot preserve incompatible codecs. The unconditional statement that only recast or an explicit flag may make these changes could lead an agent to undo safeguards and introduce overflow or invalid datasets. Qualify preservation as best-effort, with required capacity and backend-compatibility exceptions.

AGENTS.md reference: AGENTS.md:L116-L117

Useful? React with 👍 / 👎.


## Tests

- `tests/conftest.py::new_store` is parametrised over `h5ad`, `zarr2` and
`zarr3`. Write element-level tests against it, so one test body covers all
three backends.
- CLI tests use a module-level `CliRunner`. Strip ANSI codes and collapse
whitespace before matching messages, because Rich wraps them. The CLI test
files define an `_out(result)` helper for this.
- Correctness is checked against an oracle: anndata reads back what we write,
and scipy checks `recast`. Self-consistency alone is not accepted.
- `tests/test_performance.py` guards complexity by **counting** elements and
bytes read through hooks on h5py and zarr (`tests/perf_counters.py`). It never
uses a clock. A change that adds reads per row or per group will fail it.
- `tests/test_docs_are_accurate.py` parses every `adata …` line in the README,
`docs/` and the `--help` examples, and checks it against the real Click tree.
A new or renamed option has to be documented correctly in
`docs/COMMANDS.md`. A new command also needs a section there, an entry in the
table of contents and a mention in the app help.
- House style:
- test names read as sentences
(`test_a_failed_selection_keeps_an_existing_output`);
- a regression test's docstring says what used to go wrong;
- comments explain *why*.

## Releases

- Use a branch `release/X.Y.Z`.
- Set the version in `pyproject.toml` and in the `pyadata-cli` entry of `uv.lock`.
- Add a `## X.Y.Z` section to `CHANGELOG.md`, in its style:
- a summary paragraph;
- `### Added`, `### Changed` and `### Fixed` sections;
- entries that start with a bold one-line lead and an issue link.
- Merge the release branch, then push a tag with no `v` prefix. The tag
triggers:
- `publish.yml`, which checks that the tag matches pyproject, tests, builds
and publishes to PyPI;
- `quay-on-tag.yml`, which pushes the Docker image to
`quay.io/cellgeni/adata-cli`;
- `benchmark.yml`, which reruns `benchmarks/` and commits the results to
`docs/BENCHMARKS.md` and `docs/benchmarks/<tag>.json`.
- `docs/BENCHMARKS.md` is generated. To change its wording, edit
`benchmarks/page_template.md`.
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

All of the guidance lives in AGENTS.md, so that other coding agents read the same text:

@AGENTS.md
Loading