Add babel-explorer: a CLI for querying Babel cross-references via DuckDB and NodeNorm - #20
Merged
Conversation
- Add IdentifierRecord dataclass to babel_xrefs.py (resolves TODO) - Add 89 tests across 3 files: test_downloader (26), test_babel_xrefs (31), test_nodenorm (23) - Unit tests (71) use mocks and run without network; integration tests (18) use real downloads/APIs - Add session-scoped fixtures in conftest.py for shared Parquet file downloads - Parametrize integration tests over tests/data/valid_curies.txt for easy expansion - Add integration and slow pytest markers to pyproject.toml - Update CLAUDE.md and README.md with testing documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Remove _calculate_md5/_fetch_remote_md5 (too slow on 2.5-3.9 GB files) - Add sidecar .meta JSON files (ETag, Last-Modified, Content-Length, last_checked) - Three-tier logic: freshness window → HEAD/ETag check → full re-download - Add freshness_seconds param to BabelDownloader (default 3h) - Add --check-download CLI option to xrefs and ids commands (e.g. 3h, never) - Update tests: replace MD5 test classes with meta/ETag/tier coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add pytest-xdist[psutil] and filelock to dev dependencies - Enable parallel execution by default with addopts = "-n auto" - Switch DuckDB connections to in-memory mode (duckdb.connect()) to eliminate file locking that would deadlock parallel workers - Make test_data_dir teardown worker-aware (only gw0 cleans up) - Wrap download fixtures with FileLock to serialize concurrent downloads - Fix test_babel_xrefs.py: update expand= to recurse= to match renamed param Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The recurse=True path previously issued one DuckDB query per CURIE and called itself recursively (O(diameter) queries, Python stack growth). It now delegates to _get_curie_xrefs_recursive, which traverses the full connected component in a single SQL query using WITH RECURSIVE. A bidirectional `edges` CTE (subj→obj and obj→subj) collapses the two traversal directions into one recursive arm; UNION (not UNION ALL) provides automatic cycle detection. ignore_curies_in_expansion is now a no-op on the recurse=True path and emits a DeprecationWarning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BabelDownloader appends a trailing slash to url_base, but the integration skip probe joined BABEL_URL and the file path directly. A BABEL_URL without a trailing slash HEADed ".../latestduckdb/Concord.parquet", got a 404, and silently skipped the whole integration session — indistinguishable from the expected skip on a public release. Also skip rather than error when the Babel server is unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sync_cache_version() deleted the .meta sidecars, but get_downloaded_file() only enters the ETag branch when a sidecar exists — so every release rollover re-downloaded Concord (~626 MB) and Identifiers (2 GB+) in full even when byte-identical, the opposite of what the docstring claimed. Clear only last_checked and keep the ETag, so unchanged files cost one HEAD. Also delete partial .tmp downloads on a version change: they are resumed by byte offset with no If-Range validation, so a Ctrl-C (a BaseException, which the tmp cleanup in get_downloaded_file does not catch) followed by a release rollover would append the new release's bytes onto a prefix of the old one and land a corrupt Parquet that passes every later freshness check. And on HTTP 416, HEAD for the file's real headers instead of returning the 416 response's own — those describe the error body, and persisting them as the file's metadata poisoned the very cache entry the fast path just confirmed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five ways the downloader could end up with a corrupt or stale `duckdb/*.parquet` that then passed every later freshness check, because `_save_meta` stamps whatever landed on disk with the *correct* remote ETag. Once that happens the damage is permanent until the Babel release changes. - A `.tmp` left by a killed process was resumed by byte offset. The only way to reach the download at all is that the remote bytes changed, so the server appended the new file's tail to the old file's prefix. Leftover `.tmp` files are now discarded before a download starts, and cleaned up on `BaseException` so a Ctrl-C leaves nothing resumable behind. - Resumes within a run now send `If-Range` with the validator from the response they started writing from, so a file rebuilt mid-download restarts (HTTP 200, already handled) instead of splicing. - HTTP 416 was read as "already complete", but it is also what the server returns when the remote file *shrank* below the resume offset. The local size is now checked against the remote `Content-Length` first. - A stream that ended short of `Content-Length` without raising was promoted as complete. It now raises `IncompleteDownloadError` and is retried. - A failed HEAD returned "unchanged", and the caller then refreshed `last_checked`. One flaky HEAD could therefore pin the previous release's Parquet as freshly validated for the whole freshness window, right after `sync_cache_version` cleared `last_checked` for a new release. `_etag_matches` becomes `_remote_unchanged` and returns `None` for "could not check": the cached file is still used, but nothing is restamped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`record_to_dict` dropped any key ending in `label` whose value was falsy, to implement the "omit an absent label rather than emit an empty one" convention. But `extra_fields` is flattened in first, so Identifiers.parquet's own `label` column — and any other `*_label` column Babel adds — was dropped whenever it was empty or NULL. `ids --format json` then emitted some records with a `label` key and some without, and a consumer doing `row["label"]` got a KeyError. The rule only ever meant the NodeNorm-derived labels, so name them: nodenorm_label, subj_label, obj_label. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records why a leftover `.tmp` is never resumed across runs, why in-run resumes are conditional, where the two size checks sit, and why a failed HEAD does not refresh `last_checked` — so none of it gets "optimised" back into a cross-run resume or a fail-open freshness stamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The delete in `get_downloaded_file` is the safety guarantee; the sweep in `sync_cache_version` is housekeeping for files that are never re-downloaded and so never reach it. Neither covers the other's case, and dropping the wrong one reintroduces silent Parquet corruption, so say which is which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BABEL_URL was one opaque knob that had to carry both "which server" and "which
release". Splitting it matches how both Babel trees are actually laid out -- one
subdirectory per release plus a `latest/` symlink -- and makes pinning a release
a one-word change rather than a URL edit:
BABEL_RELEASES_URL=https://stars.renci.org/var/babel/ (--babel-releases-url)
BABEL_VERSION=latest (--babel-version)
The effective Babel URL is the two composed. `--babel-url` still takes a
complete URL and overrides both, for a tree that does not follow that layout,
but it is now **command line only**: with two variables already feeding the
composed URL, a third that silently outranked both would make "which release am
I actually querying?" unanswerable from the environment alone.
The defaults compose byte-identically to the URL they replace, so nothing about
the shipped behaviour changes.
Falling out of this:
- `resolve_babel_version`'s final-path-segment fallback now strips exactly
BABEL_VERSION, so a pinned release still resolves when VERSION.txt is
unreachable. `latest` still yields None, as before.
- The skew message can now suggest pinning --babel-version to the release
NodeNorm was built from, which fixes the mismatch rather than suppressing it.
- `--babel-version` rejects a value containing "://" or "..", since anyone with
muscle memory from BABEL_URL will eventually paste a whole URL into it.
- `cli()` warns if BABEL_URL is still set, so a stale .env does not silently
send someone to the wrong release.
`compose_babel_url` lives in core/downloader.py rather than cli.py because
tests/constants.py needs the identical composition and must not import Click to
get it. TestUrlConfiguration now stubs load_dotenv: it runs inside cli(), after
CliRunner(env=...) has cleared a variable, so a real .env would otherwise leak
into the assertions -- and every Translator developer is about to have
BABEL_RELEASES_URL in theirs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.env.example` is hidden on Linux and reads like a sample rather than the defaults it actually holds. `env.default` is visible in a plain `ls` and says what it is. It also gains BABEL_CHECK_DOWNLOAD, which the README documented but the old template omitted. The README and CLAUDE.md are updated throughout for BABEL_RELEASES_URL / BABEL_VERSION, and both now record why --babel-url deliberately has no envvar= so nobody adds one back. CLAUDE.md also notes that the .babel-version marker records the release the server *resolved* to rather than the one requested, so `latest` and an equivalent pinned version share a cache -- that is intended, not a bug to fix. The missing-Parquet caveat is no longer addressed only to Translator members: it now says plainly that the shipped defaults cannot serve data yet, and links the tracking issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repository is MIT licensed and has been since the first commit, but the built wheel carried no license, no author and no classifiers. Anyone inspecting the package could not tell what they were allowed to do with it. CHANGELOG.md records what 0.1.0 contains and, just as importantly, what it cannot do yet: the shipped defaults cannot query data (#16) and --labels fails the version check against public NodeNorm (#17). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the release end to end against a current Babel turned up that Concord.parquet is 4.6 GB in 2026jul22, not the ~626 MB the fixture docstring claimed. Its tests are not marked `slow`, so `pytest -m "integration and not slow"` -- documented in both README.md and CLAUDE.md as avoiding 2GB+ downloads -- would in fact pull 4.6 GB. Marking those tests slow instead would leave the non-slow integration set covering nothing that touches real data, so the marker keeps its meaning and the docs are corrected: `slow` means Identifiers.parquet, and `not slow` is not a promise of "small". Called out explicitly because it changes the sizing for #18, where CI would start pulling these files for real. Hard byte figures are dropped from the fixture docstrings for the same reason the file already gives for test counts: they drift silently and then mislead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Never commit the Translator-specific URL" has been a documented rule for as long as the repository has had a config template, and nothing enforced it. The URL was in fact the hardcoded default from the initial commit until history was rewritten to remove it, so the rule has already failed once in practice. TestCommittedConfigTemplate reads env.default and asserts that every host in it is public, that its defaults still compose to the CLI's default URL, that it documents exactly the settings the CLI reads, and that it does not resurrect BABEL_URL. Verified non-vacuous: injecting the internal URL fails it. Also pins the missing-Parquet error's wording. That message is where most people first learn the configuration scheme exists; it named BABEL_URL for as long as that variable did, and nothing would have caught the wording going stale when the variable was replaced. .gitignore gains .env.* alongside .env, because a .env.backup or .env.local holding that URL is precisely what a blanket `git add` would sweep up. env.default does not match the pattern and stays tracked. CLAUDE.md records what the history rewrite means for anyone with an older clone or an old PR link, and points at the test as the enforcement mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces the initial Babel Explorer CLI for querying Babel Parquet data through DuckDB with optional NodeNorm enrichment.
Changes:
- Adds
xrefs,ids, andtest-concordcommands with structured output. - Implements release-aware downloading, caching, recursive queries, paths, and batched NodeNorm lookups.
- Adds packaging, CI, documentation, and comprehensive tests.
Reviewed changes
Copilot reviewed 21 out of 26 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/ci.yml |
Adds lint and test workflows. |
.gitignore |
Ignores generated data and local configuration. |
.python-version |
Selects Python 3.11. |
CHANGELOG.md |
Documents the initial release. |
CLAUDE.md |
Adds repository development guidance. |
FUTURE.md |
Lists tracked future work. |
README.md |
Documents setup, configuration, and usage. |
env.default |
Provides public endpoint defaults. |
pyproject.toml |
Configures packaging, dependencies, and tooling. |
src/babel_explorer/__init__.py |
Initializes the package. |
src/babel_explorer/cli.py |
Implements Click commands and configuration. |
src/babel_explorer/core/__init__.py |
Initializes the core package. |
src/babel_explorer/core/babel_xrefs.py |
Implements DuckDB querying and graph traversal. |
src/babel_explorer/core/downloader.py |
Implements downloads, caching, and release handling. |
src/babel_explorer/core/nodenorm.py |
Implements NodeNorm lookups and batching. |
src/babel_explorer/formatting.py |
Implements console and structured output. |
tests/__init__.py |
Initializes the test package. |
tests/conftest.py |
Adds shared integration fixtures. |
tests/constants.py |
Defines shared test configuration. |
tests/data/valid_curies.txt |
Supplies integration-test CURIEs. |
tests/test_babel_xrefs.py |
Tests querying and traversal. |
tests/test_cli.py |
Tests CLI behavior and configuration. |
tests/test_downloader.py |
Tests download and cache behavior. |
tests/test_formatting.py |
Tests output formatting. |
tests/test_nodenorm.py |
Tests NodeNorm integration and batching. |
uv.lock |
Locks project dependencies. |
Suppressed comments (1)
src/babel_explorer/core/downloader.py:449
- When the HEAD response omits
Content-Length, this condition falls through and treats the temporary file as complete without verifying its size. A 416 alone does not prove completeness, so an unavailable length must also discard the partial file and restart.
remote_length = head.headers.get("Content-Length")
if (
remote_length is not None
and int(remote_length) != resume_byte_pos
):
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Two paths let a cache holding the previous release be used against a marker
naming the new one, so Concord.parquet and Identifiers.parquet could be read
together across two Babel builds — the exact failure the version marker exists
to prevent.
_is_within_freshness() returned True on float("inf") before looking at
last_checked, so the clearing sync_cache_version() does was a no-op under
--check-download never: the old release's Parquet came back with no network
call at all. Test the missing last_checked first. "never" means "do not
re-check within a release", not "ignore a release change".
sync_cache_version() also stamped the new release into .babel-version up front,
so a run interrupted between the two Parquet files left a marker claiming a
release the cache only half held; the next run saw it match and skipped the
refresh. Hand the release to _write_version_marker_if_synced() instead, which
writes it only once no .meta sidecar is still missing its last_checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "at least two CURIEs" requirement lived in _print_paths, which runs after make_downloader(), the multi-gigabyte Concord.parquet download and the full recursive query — so `xrefs MONDO:0004979 --paths` fetched 4.6 GB and then said it needed another CURIE. --paths implies --recurse, so there is no cheap version of that mistake. Check it beside the --format guard, which was already up front. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BabelExplorerGroup.invoke mapped only MissingBabelFileError to a ClickException. NodeNorm deliberately lets requests.HTTPError and ConnectionError propagate so a failed lookup is not cached, and nothing caught them, so `xrefs --labels`, `ids --labels` and `test-concord` against a down or 5xx NodeNorm ended in a Python stack trace. Catch requests.RequestException alongside it. The failure lands mid-query rather than at startup because get_babel_version() swallows its own errors, so the version check passes against an unreachable NodeNorm. That is still the right trade — a NodeNorm that cannot report a version should not block a run — but it does mean the error has to be legible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rectory BabelXRefs opened connections with a bare duckdb.connect(), whose default temp_directory is `.tmp` in the *current working directory*. Nothing is persisted, but "in-memory" is not "touches no disk": the recursive expansion materialises the whole Concord relation (4.6 GB in 2026jul22) plus a doubled edges relation, so a real `--recurse` run dropped gigabytes of spill wherever the user happened to be standing. Route every connection through BabelXRefs._connect(), which points temp_directory at <BABEL_LOCAL_DIR>/duckdb-spill/ — the directory the user already chose to hold multi-gigabyte Parquet files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gelog CLAUDE.md claimed `@functools.lru_cache` in three places; `grep -rn lru_cache src/` returns nothing. BabelDownloader caches on disk via ETags and `.meta` sidecars and memoises only `babel_version` (with `cached_property`); NodeNorm uses three plain per-instance dicts. A reader chasing LRU eviction semantics was chasing nothing. The changelog's "28 of 288 tests" was already wrong — collection reports 294 — and CLAUDE.md says not to record per-file test counts precisely because they drift silently and then mislead. The same reasoning applies here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_download_with_retry sent a bare Range whenever a retry found bytes on disk, adding If-Range only if a validator happened to be known. A server that supplies neither an ETag nor a Last-Modified therefore got an unconditional resume — and that is precisely the case where a file rebuilt between attempts splices its tail onto the old version's prefix undetectably, since what lands is then stamped with the new validator and passes every later freshness check. Having a validator is now a precondition for resuming at all; without one the partial file is discarded and the download restarts. Two existing tests encoded the old behaviour by seeding a partial file with no validator, a state get_downloaded_file never produces. They now reach the resume through a first attempt that ends short, which is how production gets there. Copilot (suppressed): src/babel_explorer/core/downloader.py — an HTTP 416 whose HEAD carries no Content-Length was treated as "already complete". 416 is also how a server answers when the file shrank below the resume offset, and with no remote length there is nothing to tell the two apart, so it now restarts as well. The restart cannot loop: the retry has nothing on disk and so sends no Range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration tests cleared the cross-reference cache with `get_curie_xref.cache_clear()`, left over from when that method was decorated with functools.lru_cache. It is a plain method over a per-instance dict now, so all ten calls raised AttributeError — invisible because every one of those tests skips without a Babel release publishing the Parquet files, which is the case for every public release. Add BabelXRefs.clear_xref_cache() and call that instead, with a unit test so the method's existence is covered by the suite that actually runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BABEL_ALLOW_VERSION_MISMATCH is read from the environment and documented in both CLAUDE.md and the changelog, but env.default omitted it, so copying the template did not in fact give you every supported setting. The omission survived because the test guarding that rule listed the settings by hand: `test_documents_every_setting_the_cli_reads` asserted against a hard-coded set, which passes just as happily when a new option is added to the CLI and forgotten in the template. It now reads the envvar= declarations off the Click commands, so the next setting cannot go undocumented the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue #12 asked for a `normalize_curies(curies)` batch method to collapse N serial round-trips into one. That shipped in this branch: NodeNorm batches at 100 CURIEs per request, and all three label paths pre-warm through it — `ids`, `xrefs --labels` and `test-concord`. Listing it as future work contradicted the code next to it. #13 is untouched and still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Sep 1, 2026
…t assumes (#31) `CLAUDE.md` had grown to 396 lines by accretion — each hard-won rule from a review round appended wherever it fit. It was accurate about this repository's internals and silent about the system those internals exist to read: it never said what Babel is, what a clique or a concord is, or why NodeNorm's version matters. An agent arriving at the `Concord.parquet` query code had no model of the data. This renames the file to `AGENTS.md`, gives it that missing context, moves the deep caching invariants to `docs/`, and removes what was being said twice. **Documentation only — no change to `src/`.** ## What's here **Renamed to `AGENTS.md`**, the filename coding agents other than Claude Code look for; the guidance was never Claude-specific. `CLAUDE.md` remains as a four-line pointer, so Claude Code still finds it. Four test docstrings that referenced the old name are updated. The rename is the first commit, so the substantive edits that follow read as edits rather than as a delete plus an add. **A Domain context section** covering only the terms this codebase actually uses — clique, preferred identifier, concord, conflation, Biolink type — and NodeNorm's relationship to a specific Babel release. It is a summary with links rather than copied upstream prose, so it cannot drift from upstream unnoticed. The part that earns the most space is the boundary between concords and cliques. Babel's own `AGENTS.md` says to answer clique-membership questions from a finished build *"never from the concords that fed it"* — and concords are exactly what this tool reads. That is not a defect, it is the purpose: `xrefs` reports the evidence Babel read, which is what you want when a merge looks wrong, while NodeNorm reports the verdict. Written down explicitly so that nobody later "improves" `xrefs` into a clique oracle. **The caching invariants moved to [`docs/Downloading.md`](docs/Downloading.md)** — near-verbatim, because the wording is hard-won and each rule records a specific failure that actually happened. `AGENTS.md` keeps a four-sentence summary and a pointer. That is ~90 lines an agent needs rarely, which had been sitting above the orientation it needs every session. **Deduplication.** The DuckDB spill directory was described in three places, the caching model in three, and the never-commit-the-internal-URL rule in three. Each is now stated once, where it belongs. `## Important Notes` is deleted outright — all three of its bullets restated earlier sections. The command examples now point at `README.md`, which documents every flag and is the user-facing reference. **Four factual corrections:** - `MissingBabelFileError` is raised on **any** 404, not only for `duckdb/` paths, as the file claimed in two places. `downloader.py:546` has no path condition. - CI budgeting pointed only at #18; #28 covers the same ground more precisely. - "recreated as #20-#24" predated #20 merging. - `README.md`'s configuration table was missing `BABEL_ALLOW_VERSION_MISMATCH`, which `env.default` has carried since #27. ## What upstream does not document Worth recording, because it shaped what could honestly be written. Neither Babel's `README.md` nor its `releases/ARTIFACTS.md` describes `Concord.parquet`, `Identifiers.parquet` or `Metadata.parquet`. `conflate` / `drug_chemical_conflate` and the `/status` `babel_version` field are absent from NodeNormalization's README. So the file schema in the new section is what **this repository's code assumes**, verified against real files — and it is labelled that way rather than presented as a published contract. Release naming, `latest` and `VERSION.txt` are undocumented upstream too. Filed upstream as [NCATSTranslator/Babel#1077](NCATSTranslator/Babel#1077), which links back to the Domain context section as the shortest statement of what is missing. ## What it deliberately does not do - **Document `search-xrefs`** — that is #21, not on `main`. - **Rewrite the version-skew wording** — #30 will change that behaviour; editing the prose now means editing it twice. - **Split Architecture or Testing into `docs/`** — those are context an agent needs often, and spreading them costs more than the length saves. ## Verification 272 unit tests pass and ruff is clean (no source changes, so both should be unchanged). Every relative link and intra-file anchor in `AGENTS.md`, `docs/Downloading.md` and `CLAUDE.md` resolves, no `.py` file still references `CLAUDE.md`, and the symbols the reworked text names were re-checked against the code. Nothing is blocking this merge. **One thing to judge:** `AGENTS.md` is 370 lines, down from 396, with a further 84 in `docs/`. Total documentation went *up*, because the Domain context section came out at 61 lines rather than the ~25 originally scoped. The extra went on the concord/clique boundary above. If that reads as too much for an agent file, the obvious trim is `## Testing` (73 lines, now the largest section).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
babel-explorer is a CLI for asking Babel why two identifiers are considered the same thing. It reads Babel's intermediate Parquet files through DuckDB and, optionally, enriches the results with labels from NodeNorm.
BabelDownloaderhandles caching and freshness,BabelXRefshandles querying,NodeNormhandles labels, andcli.pywires them together with Click.Supersedes #1, which GitHub closed and refused to reopen after this branch's history was rewritten.
Closes #12.
What's here
Three commands:
xrefs— cross-references for one or more CURIEs.--recurseexpands transitively through a singleWITH RECURSIVEDuckDB query;--pathsshows the shortest paths connecting the given CURIEs;--labelsadds NodeNorm labels and Biolink types.ids— identifier records fromIdentifiers.parquet, with--labels.test-concord— compare a proposed concordance change against NodeNorm's current cliques.xrefsandidsalso take--format json|tsv|csvfor machine-readable output.--pathsis console-only, and both of its preconditions — a console format, and at least two CURIEs — are checked before anything is downloaded. Getting either wrong otherwise costs a multi-gigabyteConcord.parquetdownload and a full recursive query before the run is rejected, because--pathsimplies--recurse.Failures from the two services this tool talks to are reported as errors rather than tracebacks.
MissingBabelFileErrorexplains that a release does not publish the DuckDB files;requests.RequestExceptionreaching the top means NodeNorm, since the downloader handles its own network failures.Configuring which Babel release to query
A Babel release is addressed as a releases directory plus a version, which is how both the public and internal trees are actually laid out — one subdirectory per release, plus a
latest/symlink:BABEL_RELEASES_URL--babel-releases-urlhttps://stars.renci.org/var/babel/BABEL_VERSION--babel-versionlatest--babel-urlPinning a release is a one-word change rather than a URL edit, which matters because pinning is the honest fix for a NodeNorm version mismatch. Precedence runs flag > environment variable >
.env> built-in default, and only public URLs are committed.--babel-urltakes a complete URL for a tree that does not follow that layout. It is command-line only, with noenvvar=, deliberately: two variables already feed the composed URL, and a third that silently outranked both would make "which release am I actually querying?" unanswerable from the environment alone.cli()warns if the pre-refactorBABEL_URLis still set, so a stale.envfails loudly instead of silently pointing somewhere else.compose_babel_urllives incore/downloader.pyrather thancli.pybecausetests/constants.pyneeds the identical composition and must not import Click to get it.The committed template is
env.default— visible in a plainls, unlike a dotfile.Babel version handling
The release is resolved from
VERSION.txt, falling back to the final URL path segment — which under this scheme is exactlyBABEL_VERSION, so a pinned release still resolves whenVERSION.txtis unreachable, whilelatestyieldsNoneas before.BABEL_LOCAL_DIRholds one release at a time. When it changes, onlylast_checkedis cleared from the.metasidecars, so the existing ETag path re-checks each file and re-downloads only what actually changed — the Parquet files are never deleted, and an unchanged file costs one HEAD rather than a fresh multi-gigabyte download. The.babel-versionmarker records the release the server resolved to rather than the one requested, solatestand an equivalent pinned version share a cache instead of thrashing it.That marker is written after the cache catches up, not when the change is spotted. It claims "the local cache holds this release", which is only true once every cached file has been re-validated against it, so
_write_version_marker_if_synced()stamps it once no.metasidecar is still missing itslast_checked. Writing it up front would leave a run interrupted betweenConcord.parquetandIdentifiers.parquetwith a marker naming the new release over a half-old cache, and the next run would see it match and skip the refresh entirely. The cost is that a cached file nobody asks for holds the marker back indefinitely, at one HEAD per run; that is the honest answer, since the file really is still from the previous release.--check-download neversuppresses re-checks within a release, not across one._is_within_freshness()therefore tests for a missinglast_checkedbefore thefloat("inf")shortcut — reversed,neverwould hand back the previous release's Parquet with no network call at all, under a marker naming the new release, and nothing would ever notice.--labelsrefuses to mix a NodeNorm built from one release with cross-references from another, since the result would be silently wrong rather than obviously wrong.--allow-version-mismatchoverrides it; pinningBABEL_VERSIONfixes it properly.--recursenever consults NodeNorm, so it does not trigger the check.Partial downloads cannot corrupt the cache
A corrupt Parquet here is permanent: whatever lands on disk gets stamped with the correct remote ETag and then passes every later freshness check. Five routes to that are closed:
.tmpleft by a killed process is discarded before each download rather than resumed. Resume is by byte offset, the only way to reach the download at all is that the remote bytes changed, and an orphaned.tmpcarries no record of which version its bytes came from. Cleanup catchesBaseException, so Ctrl-C leaves nothing resumable behind.If-Range, so a file rebuilt mid-download restarts instead of splicing two versions together.Content-Length— 416 is also what a server returns when the file shrank below the resume offset.Content-LengthraisesIncompleteDownloadErrorand is retried, rather than being promoted as complete.last_checked. One flaky HEAD could otherwise pin the previous release's Parquet as freshly validated for the whole freshness window..tmpfiles are deleted in two places on purpose;CLAUDE.mdrecords which one is the safety guarantee and which is housekeeping, so neither gets removed later as redundant.Querying
DuckDB connections are ephemeral and in-memory, but "in-memory" is not "touches no disk": a larger-than-memory query spills, and DuckDB's default
temp_directoryis.tmpin the current working directory. The recursive expansion materialises the whole Concord relation, so every connection goes throughBabelXRefs._connect(), which pointstemp_directoryat<BABEL_LOCAL_DIR>/duckdb-spill/— the directory the user already chose to hold multi-gigabyte files, rather than wherever they happened to be standing.What it deliberately does not do
.tmpcannot be proven to belong to the file being fetched, so it is discarded. Making it safe means persisting the validator alongside the.tmp; scoped in Resume an interrupted download across runs, safely #15.env.default.TestCommittedConfigTemplateguards the template, but the original leak came through a default value incli.py. A tree-wide scan is Scan for committed secrets in CI: non-public Babel URLs and Google Sheet IDs #25.normalize_curies()collapses a clique into one request per 100 CURIEs, which is why this closes Batch NodeNorm lookups to reduce N round-trips when --labels is set #12.)Known limitations at v0.1.0
This ships knowingly non-functional against its own defaults, so people can try the tool now rather than after the Babel side catches up. The CLI says so clearly rather than failing mid-download.
duckdb/Concord.parquetorduckdb/Identifiers.parquet. Publish the DuckDB Parquet files in public Babel releases #16 — must close before v1.0.0.2025sep1against public2025dec11, so--labelsfails the skew check against the defaults. Update NodeNorm dev to a current Babel so --labels works out of the box #17Testing
261 unit tests and 13 NodeNorm integration tests pass. 28 Parquet-dependent integration tests skip without a Babel release publishing the files — that is the expected result, not a broken environment.
Verified end to end against a Translator releases tree: the composed URL resolves
2026jul22, downloadsConcord.parquet, writes a correct.babel-versionmarker, and returns cross-references forMONDO:0004979. The version-marker sequencing was verified separately by simulating a2025dec11→2026jul22change under--check-download never: both Parquet files are re-fetched, and the marker flips only after the second one lands.Note that
not slowis not a promise of "small".Concord.parquetis 4.6 GB in2026jul22and its tests are not markedslow, because excluding them would leave the non-slow integration set covering nothing that touches real data. This matters for sizing #18.Nothing is blocking this merge
Everything outstanding is tracked in #12, #13, #15, #16, #17, #18, #19 and #25. None of it makes what ships here wrong — #16 and #17 constrain what the defaults can do, and both are stated plainly in the README,
CHANGELOG.mdand the CLI's own error messages.Review history — three review rounds and a history rewrite. Kept for anyone tracing why a particular line looks the way it does; the durable conclusions are in the code comments, CLAUDE.md and the sections above.
First review round found six defects, each fixed in its own commit with a regression test:
--recursetriggered the NodeNorm version check it no longer needed;ids --labelssilently dropped the NodeNorm label because Identifiers.parquet's ownlabelcolumn overwrote it; a version change forced a full re-download instead of an ETag re-check; a stale.tmpcould splice two releases into one corrupt Parquet; the HTTP 416 fast path persisted the error response's headers as the file's metadata; and the integration skip probe did not normalise a slashlessBABEL_URL, so it probed.../latestduckdb/...and silently skipped the entire integration suite.Second review round found six more, all but one in the downloader's resume path — the five now described under "Partial downloads cannot corrupt the cache", plus
record_to_dictdropping Identifiers.parquet's ownlabelcolumn whenever it was empty, because the omit-when-absent rule matched on alabelname suffix rather than the three NodeNorm-derived field names. That madelabelpresent on some rows of a json/tsv/csv run and missing on others.The first round's
.tmpfix was narrower than it looked: it swept.tmpfiles only on a Babel version change, which does not cover a content change within one release or a rebuild in place. The second round replaced it with an unconditional discard before every download. Both deletes are kept, for different reasons, which is whyCLAUDE.mdspells out which is which.Copilot review found four threads and one suppressed comment, all five real. Two were in the resume path and are folded into the rules above: a retry sent a bare
Rangewhenever no validator was known, which is exactly the case where a splice cannot be detected afterwards; and a 416 whose HEAD carried noContent-Lengthwas read as "already complete" when it is equally the answer for a file that shrank. Two existing tests had encoded the first behaviour by seeding a partial file with no validator — a stateget_downloaded_filenever produces — and were rewritten to reach the resume the way production does. The others: tenget_curie_xref.cache_clear()calls left over from anlru_cachethat no longer exists, raisingAttributeErrorin integration tests that skip and so never ran;BABEL_ALLOW_VERSION_MISMATCHmissing fromenv.default, which survived because the test guarding that rule listed the settings by hand rather than reading them off the CLI; and this PR's own batched NodeNorm lookups still listed as future work.Third review round found seven, in five commits. Two were the same defect from opposite ends and are the reason the version-marker rules above are stated so explicitly:
_is_within_freshness()returnedTrueonfloat("inf")before looking atlast_checked, so the whole cross-release refresh was a no-op under--check-download never; and the marker was stamped before anything had been re-downloaded, so an interrupted refresh looked complete to the next run. Both letConcord.parquetandIdentifiers.parquetbe read together across two Babel builds, which is precisely what the marker exists to prevent. The other three code findings:xrefs --pathsrejected a single CURIE only inside_print_paths, after the 4.6 GB download; NodeNorm's deliberately-uncaught HTTP errors reached the user as a stack trace; and DuckDB spilled into the working directory.Two were documentation drifting from the code —
CLAUDE.mddescribed@functools.lru_cachein three places where none exists (disk caching by ETag,cached_property, and per-instance dicts respectively), andCHANGELOG.mdcarried a hard-coded test count that was already wrong, in a repo whose ownCLAUDE.mdsays not to record them because they drift silently and then mislead.History rewrite (2026-09-01). The internal releases URL had been the hardcoded default from the initial commit until it moved into
.env, leaving it in 178 of 180 commits across six branches of a public repository. Every commit was rewritten to remove it, verified by four independent checks before anything was pushed, with every changed line confirmed to be that substitution and nothing else. GitHub then refused to reopen the affected PRs, because their original head commits no longer exist — hence #20 here, and #21-#24 replacing #4, #6, #7 and #11. The rewrite does not un-publish anything: GitHub retains pre-rewrite objects reachable by SHA, and the tree the URL pointed at still serves 200 unauthenticated. Both are being handled outside this repo.Verifying the release caught a stale claim.
Concord.parquetis 4.6 GB in2026jul22, not the ~626 MB a fixture docstring had claimed, sopytest -m "integration and not slow"— documented in two places as avoiding 2 GB+ downloads — would in fact pull 4.6 GB. The docs were corrected rather than the marker, and hard byte figures were dropped from the fixture docstrings for the same reason the repo already gives for test counts: they drift silently and then mislead.