Skip to content

Reconcile main with dev: adata-cli 0.5.0 - #10

Merged
Claptar merged 40 commits into
mainfrom
chore/reconcile-main-with-dev
Sep 15, 2026
Merged

Claptar merged 40 commits into
mainfrom
chore/reconcile-main-with-dev

Conversation

@Claptar

@Claptar Claptar commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Brings main up to date with everything merged into dev (#6, #7, #8, #9), and settles the one commit main had that dev did not.

The divergence

main carried 32520d3"Install duckdb-cli alongside csvkit in Dockerfile" — committed directly to main and never merged back. dev never had it, so a plain merge would have reverted it.

That commit's intent is preserved, by better means. It added duckdb-cli to a side virtualenv that existed to hold csvkit. The Dockerfile on dev drops csvkit entirely and installs duckdb as a single static binary with no venv, so it cannot conflict with the project's dependencies. The conflict was resolved in favour of dev's version.

Verified on the merged result:

$ docker run --rm adata-cli:reconcile --version
0.5.0
$ docker run --rm --entrypoint duckdb adata-cli:reconcile -noheader -list -c "SELECT 'duckdb ' || version()"
duckdb v1.1.3

What lands on main

The whole 0.5.0 line — see CHANGELOG.md. In short:

  • Renamed to adata-cli; command is adata; info is now view. h5ad and info remain as deprecating aliases until 1.0.0.
  • Format compatibility restored — the CLI could not read any file written by anndata ≥ 0.11. Verified in CI against stores written by anndata 0.8, 0.9, 0.10, 0.11, 0.12 and 0.13, in both formats.
  • New commands: ls, create, split (implementing a obs.col based splitting #2), concat, and subset --obs-query.
  • Issue Feature requests: piping into stdout, arbitrary entries #4 closed: stdout/stderr split, arbitrary entry paths, ls.
  • 945 tests, coverage 92% with a 90% floor in CI.

Two things this unblocks

GitHub Pages. It is configured correctly (main / /docs, status built) but currently 404s, because main's docs/ has no index.md. Merging this brings https://cellgeni.github.io/adata-cli/ up.

Releasing. publish.yml fires on a tag, runs the full suite, and refuses to publish unless the tag matches version in pyproject.toml — currently 0.5.0. Note the distribution is pyadata-cli, not adata-cli: the short name was claimed on PyPI on 2026-04-04 by an unrelated project. The import package and command are unaffected.

Before tagging

  • Tag 0.3.2 currently points at a commit on dev, not on main. Worth settling, since publish.yml now triggers on tags.
  • A TestPyPI dry run needs its own pending publisher on test.pypi.org; only the pypi environment is configured so far. workflow_dispatch with target=testpypi is the rehearsal path.

🤖 Generated with Claude Code

Claptar and others added 30 commits March 23, 2026 12:50
…empty groups are included in the subset output
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
The CLI could not read any file written by anndata >= 0.11. Since pandas'
future.infer_string became the default, anndata writes obs/_index as a
nullable-string-array *group* rather than a dataset, and axis_len required a
dataset, so view, export dataframe and subset all failed outright on every
recent file. HDF5 -> Zarr conversion was also broken on any store with string
columns, and subset silently dropped raw/.

Rename the project to adata-cli (package src/adata, command `adata`) and
rename `info` to `view`; both old names remain as deprecating aliases through
0.x.

Add src/adata/elements/ as the single home for on-disk format knowledge --
spec (encoding constants), strings (backend dtype reconciliation), read (every
historical layout) and write (current spec only). The import, export and
subset paths now share it instead of each reimplementing the format.

Fixes:
- resolve_index/element_len accept a group-valued index, so modern files read
- create_dataset resolves text to variable-length UTF-8 per backend, so all
  four HDF5/Zarr pairings convert with categoricals and nullable dtypes intact
- subset_axis_group narrows group-valued columns instead of copying them
  whole, which had been producing stores with mismatched column lengths
- raw/ is subset against its own var axis; unrecognised top-level keys are
  copied with a warning rather than dropped
- every written element is tagged; None round-trips via anndata's `null`
  encoding instead of an invented marker attribute
- categoricals survive a CSV round-trip
- sparse subsetting streams in blocks (byte-exact against scipy, CSR and CSC)
- get_entry_type dispatches on encoding-type before falling back to structure
- column-order is honoured on export

New: `ls` for any HDF5/Zarr store including .loom; dataframe export from any
path; results on stdout with status on stderr; npy export to stdout.

tests/test_anndata_roundtrip.py has anndata write the fixtures and read the
results back, with OldFormatWarning promoted to an error. The CI matrix runs
tests/ on 3.12 and 3.13 rather than naming files individually, which is why
test_storage_root_attrs.py had never run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four commands, all built on the elements layer added in the previous change
rather than each reimplementing the format.

`create` writes a valid empty store -- root attributes, obs/var frames with
real indices, tagged mapping groups -- so `import` has something to fill in.
`import` loses its obs/var restriction and gains an `image` subcommand, so a
whole object can be assembled from the command line.

`split` writes one store per distinct value of an annotation column, with
label sanitisation, collision suffixes and a CSV manifest following the
conventions of cellgeni/scraft's split_h5ad, but streaming rather than reading
the source into memory. Closes #2.

`subset` gains --obs-query/--var-query over a small predicate language
(==, !=, <, <=, >, >=, in, not in, and, or, not, parentheses) evaluated
chunk-wise. Only the columns a query mentions are read. This deliberately is
not SQL: anything more involved is better served by exporting to CSV and using
duckdb. A var query propagates to raw/ by name, so raw stays consistent.

`concat` joins along obs with inner/outer alignment, --label/--keys/
--index-unique, and merge strategies for var and uns. Verified against
anndata.concat: var order, obs order and X values agree exactly for both
joins. obs columns keep their dtypes -- categoricals union their category
sets and remap codes, nullable columns keep their masks, and a column missing
from one input is padded rather than dropped. obsp/varp and raw are not
carried over, as in anndata's own implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prepares the first PyPI release and gives the project documentation that
lives somewhere other than the repo root.

Packaging: `Documentation` was "README.md", which is not a URL and PyPI
rejects it. Adds license-files, Operating System and Typing classifiers,
Python 3.13, py.typed, and a Changelog URL. `__version__` reads from installed
metadata rather than being duplicated, and `adata --version` reports it.

publish.yml publishes on a tag using PyPI trusted publishing, so there is no
API token to hold. It reuses tests.yml via workflow_call, so a release cannot
skip the suite, and refuses to publish when the tag does not match the version
in pyproject.toml -- nothing enforced that before, and 0.3.2 was tagged against
a tree declaring 0.3.1. A workflow_dispatch path targets TestPyPI first.

Docs become a GitHub Pages site served from docs/ with plain Jekyll, so the
files stay readable as markdown on github.com. Adds index.md, a full
COMMANDS.md reference, and to each ELEMENTS doc a section on what this tool
actually does with each element -- they were pure spec transcriptions saying
nothing about the CLI. Documents the `null` encoding (absent from the upstream
prose spec but written by anndata 0.12+) and, for Zarr, the v2/v3 differences
that matter when copying between stores.

csvkit is replaced by duckdb in the tutorial and the image. The tutorial now
leads with `--obs-query`, which covers the example it used csvsql for, and
keeps duckdb for filters that genuinely need SQL. The image drops the csvkit
side-venv for a single static duckdb binary, and passes the --locked flag its
comment already claimed.

Verified: the wheel installs into a clean venv and runs; the image builds and
the documented duckdb workflow runs inside it end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The action posts its summary as a PR comment, which needs pull-requests:
write. Without it the step 403s and fails the job even when every test
passed, which is what happened on #6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A PR stacked on another feature branch matched neither branch filter, so it
reported no checks at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CSC matrices were silently dropped by concat. `kinds == {"csc_matrix"}` matched
no branch, so the code warned -- misreporting agreeing inputs as disagreeing --
and returned successfully, writing an output with no X at all. CSC is now
concatenated as CSC: for each target column, each input's row indices are
appended with that input's row offset added, which stays sorted without a
re-sort. Encodings are also checked before the output store is created, so a
genuine mismatch fails loudly instead of leaving a partial store.

Default concat keys came from filename stems, so inputs in different
directories sharing a name produced duplicate keys. --label writes those as
categorical categories, which must be unique, so the command reported success
while producing an output pandas could not read. Duplicates are now rejected,
for explicit --keys too.

`import image` replaced its destination unconditionally, so
`import image data.h5ad obs img.png` deleted the obs dataframe. Images are now
refused for paths that must hold a dataframe, and validated against the axis
for everything else.

`raw/var` was not recognised by validate_dimensions, so it could be replaced at
any length while raw/X kept its width. raw/var, raw/X and raw/varm/* are now
validated against raw's own var axis, taken from raw/X.

Under --uns-merge same/unique, non-dict groups were compared by their child key
names alone, so two dataframes with the same columns but different values
looked equal and the first input's was kept. Comparison now recurses into
contents and attributes, with a size cap above which an element is treated as
equal to nothing. Dict groups already recursed, which is why this only showed
up for dataframes, sparse matrices and categoricals.

A nullable var column surviving a merge was rendered as text, turning missing
values into empty strings and losing the numeric dtype. It now keeps its
values, mask, encoding and na-value.

Each fix has a regression test that fails without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#6

A nested string list such as [["a","b"],["c","d"]] was flattened into a
length-4 vector on import, because the string branch of _write_json_list
reshaped before writing. The shaped array is now passed through, and
write_string_array no longer list()s an ndarray, which would flatten it again.

Exporting a `null` element emitted its storage placeholder rather than None:
the string "Empty(dtype=...)" on HDF5, and false on Zarr. _dataset_to_jsonable
now recognises the encoding, so JSON payloads containing null round-trip
exactly.

Chasing those two through Zarr surfaced a larger one. anndata writes a
consolidated metadata index at the root of a .zarr store. New members do land
on disk, but every reader honouring that index -- anndata included -- keeps
reading the stale snapshot, so an `--inplace` import into an anndata-written
store reported success and then appeared to do nothing. Writable stores are
now opened with use_consolidated=False and the index is rewritten on close.
Only stores written by anndata were affected, which is why the existing Zarr
tests, which build their own stores, never caught it.

That in turn exposed subset_raw_group assuming `raw` is a group. anndata
writes a null-encoded placeholder array there when an object has no raw, so
on Zarr the subset crashed with "'Array' object has no attribute 'keys'". A
non-group `raw` is now copied verbatim.

Each fix has a regression test that fails without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite checked this tool mostly against stores it built itself, which
cannot catch what actually broke it twice: anndata changing how it writes a
file. tests/test_anndata_versions.py now builds a reference store with each of
anndata 0.8, 0.9, 0.10, 0.11, 0.12 and 0.13 -- each in its own uv-assembled
environment with era-appropriate pins -- and checks that the CLI reads it and
that what the CLI writes reopens in that same release. 204 cases across both
formats, marked `integration` and run as a separate CI job.

The pins are load-bearing: a modern pandas makes string columns a type the
older releases cannot write, and pandas 1.x has no Python 3.12 wheels, so 0.8
and 0.9 build against 3.11.

New unit modules cover the element layer against HDF5, Zarr v2 and Zarr v3
(test_elements.py), the storage layer including cross-version copying
(test_storage.py), the file-format paths and their failure modes
(test_formats.py), dimension validation for every axis-bearing path
(test_validate.py), the command surfaces reached only indirectly
(test_commands_coverage.py), and invariants that must hold for any data --
subsetting everything is the identity, split partitions exactly, concat undoes
split (test_invariants.py).

Fixed while writing them: Zarr v3 -> v2 copies failed because v3 codec objects
were forwarded into a v2 array, which only accepts numcodecs -- the mirror of
the filters bug fixed earlier. Codecs now only travel between stores of the
same Zarr version. Also silenced zarr's warning about consolidated metadata at
our own call site, since writing it is deliberate.

Adds a shared multi-backend store fixture, replacing the hand-rolled builders
new tests would otherwise have copied, and docs/TESTING.md.

741 unit tests plus 204 compatibility tests, up from 244. Coverage 85.5% ->
92.4%, with a 90% floor enforced in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
publish-pypi depended on check-version, which only runs for tags. GitHub skips
a job whose dependency was skipped, so a manual dispatch targeting PyPI would
never have published anything. Both publish jobs now use always() with the
success of build asserted explicitly, accepting check-version as either passed
or not applicable.

The Dockerfile sets ENTRYPOINT ["adata"], so the README and index examples
were running `adata adata view ...`, which Typer rejects. Dropped the repeated
executable; verified against the built image.

COMMANDS.md listed `--zarr-format` among split's options, but the flag did not
exist -- `adata split ... --zarr-format 2` failed with an unknown option.
Implemented it rather than deleting the claim: split already inherited the
source store's Zarr version, so only the override was missing.

Adds tests/test_docs_are_accurate.py, which extracts every `adata ...`
invocation from the README and docs and checks each command and option against
the real --help. That alone would not have caught this one, since the claim
lived in prose rather than a fenced example, so sentences naming an option and
the commands offering it are checked too. Both checks fail if the flag is
removed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docs test scraped `--help` output, which Rich wraps to the terminal width.
It passed on a wide local terminal and failed in CI, where the width is
narrower and long option names break across lines -- an assertion about
rendered UI rather than about the CLI.

Options now come from the Click command objects behind the Typer app, so the
check is exact and width-independent. Verified passing at COLUMNS 40 through
200, and still failing at both widths when --zarr-format is removed from
split again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claptar and others added 10 commits September 15, 2026 13:59
…ilt fixtures

The cross-version codec fix was incomplete. _target_zarr_format defaulted an
unspecified target to v3, and the subset paths never passed one, so
`adata subset src.zarr -o out.zarr --zarr-format 2` on a v3 source forwarded
v3-only options into a v2 array. It surfaced first through sharding rather
than compressors: "Zarr format 2 arrays can only be created with shard_shape
set to None".

dataset_create_kwargs now takes the destination group and reads the version
from it, so a call site that forgets cannot be wrong; an unknown target
carries nothing version-specific rather than guessing.

Fixing that exposed a second problem in the same area. Zarr requires a shard
to be a whole number of chunks, so clamping a chunk to the subset size
invalidates the source's shard geometry -- v3 to v3 then failed too. Chunk
clamping is now one helper covering every dimension, and it drops sharding
when it changes a chunk, since sharding is storage layout rather than data.
The dense path had its own inline clamp that missed this; it now shares the
helper.

Separately, the compatibility suite turned any failed fixture build into a
skip, so a broken release pin could have left that CI job green having checked
nothing. A build failure now fails wherever the fixtures are required -- CI,
or ADATA_REQUIRE_VERSION_FIXTURES=1 -- and stays a skip locally, where a
broken environment should not block unrelated work. The job also asserts that
a plausible number of cases were collected, so a marker or collection mistake
cannot pass as success either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard added in the previous commit counted lines containing "::" in
`pytest -q --collect-only` output, which prints "<Function ...>" instead --
so it always found zero and failed the job it was meant to protect. That is
the same mistake as the help-scraping docs test: asserting on formatted
output rather than on data.

The check now parses the JUnit XML the run already produces and requires at
least 150 executed cases, which is what "the job actually ran something"
means. Verified both ways: 204 ran against a real run, and 0 when every case
is skipped via ADATA_SKIP_VERSION_FIXTURES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The short name was free when this was planned but has since been claimed by an
unrelated "AData CLI" project, uploaded 2026-04-04. Publishing would have
failed against a project we do not own, so the distribution is now
`pyadata-cli`, matching the pending publisher configured on PyPI.

Only the distribution name changes. The repository, the import package, the
command and the documentation all stay `adata` / `adata-cli`, so the sole
user-visible difference is `pip install pyadata-cli`. That mismatch is
explained where the install is documented rather than left to surprise anyone.

__version__ reads its metadata under the new name, so `adata --version` keeps
working; verified by installing the built wheel into a clean environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A CI run hung for the full 20-minute job timeout and was cancelled, reporting
only that an orphan pytest process had been killed. The last test to complete
was the one before the new split --zarr-format cases; py3.13 passed the same
commit, and it does not reproduce locally, so it is timing-dependent.

subset_h5ad wrapped its whole body in `console.status(...)` and then opened a
`Progress` on the same console inside it. Rich allows one live display per
console, and `split` repeats that pattern once per output group. The spinner
is now stopped explicitly once the selection is known, before the progress bar
starts, with a finally to guarantee it stops on any path.

Whether or not that was the cause, a hang should not cost twenty minutes and
leave no evidence. pytest-timeout is now a dev dependency with a 300s
suite-wide limit, so a stuck test fails with a traceback pointing at it; the
compatibility tests get 1800s, since assembling six environments on a cold uv
cache is legitimately slow. Verified the timeout fires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rename to adata-cli; restore AnnData format compatibility (0.4.0)
Add create, split, concat and query-based subsetting (0.5.0)
Docs site, PyPI packaging, duckdb (1.0.0 prep)
Test against six real anndata releases; coverage 85% → 92%
Copilot AI lite review requested due to automatic review settings September 15, 2026 14:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-15T14:14:58.370393Z 9baac85 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown

Test Results (py3.13)

825 tests   825 ✅  1m 7s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 9baac85.

@github-actions

Copy link
Copy Markdown

Test Results (py3.12)

825 tests   825 ✅  1m 28s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 9baac85.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9baac85490

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/core/concat.py
Comment on lines +951 to +952
if name not in group or (where < 0).any():
aligned.append(_MISSING)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve var columns when performing an outer merge

When --join outer combines inputs with different var indices, every where containing an absent variable is treated as if the entire column were missing. For example, inputs indexed by [g1,g2] and [g2,g3] both produce a mapping containing -1, so even --merge first marks both copies as _MISSING and silently drops the var column. Reindex each present column onto the union with an appropriate missing representation instead of rejecting the whole column.

Useful? React with 👍 / 👎.

Comment thread src/adata/core/concat.py
for col in columns:
if col is None:
continue
for category in read_categories(col):

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 Pass dataframe context when reading legacy Zarr categories

When concatenating a legacy Zarr dataframe whose categorical column is a code array and whose labels live under obs/__categories, this call lacks the parent dataframe group. read_categories can resolve that legacy layout only when parent_group is provided, so concat raises KeyError for these otherwise supported stores instead of combining the column.

Useful? React with 👍 / 👎.

Comment thread src/adata/core/concat.py
# half-written store behind.
check_matrix_encodings(roots, console)

with open_store(output, "w", zarr_format=zarr_format) as dst_store:

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 Retain the source Zarr version during concat

For .zarr output with no explicit --zarr-format, this passes None, causing zarr-python to create its default v3 store even when every input is v2. This silently upgrades v2 data and can make the result unreadable by older consumers, contrary to the source-version behavior documented in docs/COMMANDS.md:232-235; infer the format from the opened input stores before creating the destination.

Useful? React with 👍 / 👎.

Comment thread src/adata/core/concat.py
Comment on lines +912 to +915
if label:
if label in written:
raise ValueError(
f"--label {label!r} collides with an existing obs column."

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 Validate label collisions before creating the output

When --label matches an obs column retained by the selected join, this error is raised only after the output store and its partial obs dataframe have already been written. The failed command therefore leaves an incomplete path behind, and the next corrected concat refuses to run because the output exists; perform this collision check before opening the destination or remove the partial output on failure.

Useful? React with 👍 / 👎.

@Claptar
Claptar merged commit 0c69116 into main Sep 15, 2026
5 checks passed
@Claptar
Claptar deleted the chore/reconcile-main-with-dev branch September 15, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants