Skip to content

Initial implementation: download, store, and export PubMed abstracts - #1

Open
gaurav wants to merge 38 commits into
mainfrom
initial-implementation
Open

Initial implementation: download, store, and export PubMed abstracts#1
gaurav wants to merge 38 commits into
mainfrom
initial-implementation

Conversation

@gaurav

@gaurav gaurav commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

WIP

  • We need to export the PMCIDs and DOIs as well -- these may be present in different files, but Babel knows how to download them, so it should be easy to add.

Summary

Initial implementation of pubmed2db: a uv/click tool that downloads all PubMed abstracts, loads them into a full-version-history DuckDB database, and exports the latest version of every abstract to JSON (for Node Annotator / ElasticSearch) and Parquet (for downloadable queries).

Pipeline download → load → export:

  • download — reuses cthoyt/pubmed-downloader for bulk baseline/update fetching; adds .md5 sidecar tracking so new/changed checksums drive incremental reloads.
  • parse — drives the XML iteration itself, calling @cthoyt's _extract_article for the rich record while additionally capturing raw PubDate components (full date fidelity) and <DeleteCitation> PMIDs.
  • load — full history, every version tagged with source_file provenance; latest_article view selects the newest non-deleted version per PMID. Journal names come from the NLM Catalog.
  • export — one configurable command: sharded NDJSON with DocumentMetadataAPI field names (empty string, never null; pub_month as a 3-letter abbreviation) and per-table Parquet (latest or full history).

The database uses PubMed's own field names; DocumentMetadataAPI names are applied only at JSON export.

Design decisions

  • Reuse pubmed-downloader as-is (no upstream changes) for download + parse; DuckDB is our store (we don't use its JSONL cache).
  • Keep full version history; fully normalized tables; DuckDB for native Parquet + scale.
  • MD5 is low-priority (HTTP downloads are reliable, PubMed files immutable): store the checksum and reload only on new/changed files.

See CLAUDE.md for architecture and FUTURE.md for follow-ups (replacing Babel's downloader, scale/perf, etc.).

Known issue worked around

pubmed_downloader.catalog.process_journal_overview() (≤ 0.0.14) raises on the real J_Entrez.txt (its Journal model requires start_year/end_year the file omits), so we parse the overview file ourselves. Tracked in FUTURE.md to revert once fixed upstream.

Verification

  • 34 tests pass (no network): parse fidelity, latest-version/delete logic, idempotent + MD5-change reloads, journal parsing, JSON spec fields, Parquet filtering, CLI end-to-end.
  • Live run on one real update file: MD5-verified download → loaded 16,664 articles + 56 deletions → 41,923 journals → exported 16,664 docs across even NDJSON shards (zero nulls, journal names/abbrevs resolved, months normalized) and 15 consistent Parquet files.

🤖 Generated with Claude Code

gaurav and others added 26 commits June 19, 2026 14:45
Set up the uv project: pyproject with the pubmed-downloader/duckdb/click/
pydantic/requests/tqdm/lxml dependencies, the pubmed2db console script, a
src/ layout, and the pytest config. Ignore the JetBrains .idea/ directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Normalized, full-version-history schema using PubMed's own field names: an
article table plus child tables (abstract_text, author, mesh_heading, grants,
citations, article_id, history, ...), a source_file registry, and the
latest_article view that selects the newest non-deleted version per PMID.
db.py opens/initializes the database and parses filenames into a chronological
file_order_key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
download.py reuses pubmed_downloader to fetch baseline/update files and adds
.md5 sidecar tracking so new/changed checksums drive incremental reloads.
parse.py drives the XML iteration itself, calling cthoyt's _extract_article for
the rich record while additionally capturing the raw PubDate components (full
date fidelity) and <DeleteCitation> PMIDs, neither of which the library's
pipeline exposes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loads parsed files into the normalized tables tagged with their source_file
provenance, so every version of a PMID coexists; reloading a file is
idempotent. needs_load drives incremental/MD5-change reloads. Journals are
built from the NLM Catalog overview file, parsed directly because
pubmed_downloader's process_journal_overview raises on the real J_Entrez data
(its Journal model requires start/end years the file omits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One configurable export: sharded NDJSON using DocumentMetadataAPI field names
(empty string, never null; pub_month as a 3-letter abbreviation) and per-table
Parquet (latest version or full history). CLI wires up download, journals,
load, export, and a combined update command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
34 tests covering parse fidelity (raw/partial/MedlineDate dates, deletions),
full-history loading, latest-version selection, idempotent and MD5-change
reloads, journal parsing, JSON spec fields (empty-string-not-null), Parquet
filtering, and the CLI end to end. Readable XML fixtures live under
tests/fixtures and are gzipped into proper filenames at test time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
README usage notes, CLAUDE.md (architecture, module map, why we reuse
pubmed-downloader as-is and drive parsing ourselves, the journal-overview
upstream bug), and FUTURE.md (replacing Babel's downloader, reverting the
journal workaround upstream, scale/perf, data-fidelity follow-ups).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the CASE WHEN ? THEN now() ELSE NULL END idiom from the INSERT,
which hid timestamp generation inside SQL and prevented callers from ever
supplying an explicit datetime. Now register_source_file converts the bool
to datetime.now(timezone.utc) or None before binding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use hashlib.file_digest() instead of a manual chunk-read loop
- Remove dead fetch_published_md5 (its logic was already inlined in _sync_kind)
- Move MD5_DIR.mkdir() before the loop instead of calling it on every iteration
- Flatten the doubly-nested registry dict comprehension in sync() to {r[0]: r[1] for r in ...}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
export_json no longer queries SELECT count(*) FROM latest_article before
streaming rows; shards are assigned round-robin (index % shards), which
is one fewer full scan of the view.

_MONTH_ABBRS set removed; calendar.month_abbr supports __contains__ directly
and already covers the same values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The closure used nonlocal to rebind record/issns and returned a value
that had to be captured and checked at each call site. Replacing it with
an inline yield + reset at each --- boundary is shorter and easier to follow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds data/.gitkeep so the download destination exists in the working tree.
Tightens .gitignore from /data/ (ignores the directory entry itself, which
prevents force-adding children) to /data/* + !/data/.gitkeep, which ignores
the contents while keeping the skeleton tracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--data-dir (default data/pubmed, or $PUBMED2DB_DATA_DIR) is added to the
top-level group so a single flag redirects both the download location and
the database for all subcommands. It sets PYSTOW_HOME before any
pubmed_downloader import so pystow resolves its module paths under data_dir.

The database now defaults to <data-dir>/pubmed.duckdb instead of pubmed.duckdb
in the working directory; still overridable via --db or $PUBMED2DB_DB.

README usage examples updated to show explicit data/pubmed paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pubmed_downloader creates its own pubmed_downloader/ subdirectory under
PYSTOW_HOME, so data/pubmed as the root was one level too deep. Using
data/ lets pystow manage its own layout naturally.

Update README and CLAUDE.md to reflect the new default and note that the
layout differs from Babel's download cache (no sharing for now).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The loader inserted each parsed file's rows with executemany on a
parameterized INSERT, which DuckDB (a columnar store) runs row by row at
~2.5k rows/s. That made `load` take ~20 min per 30k-article file — ~30+
hours for a full baseline.

Register each file's row batches as Arrow tables and insert them columnar
via `INSERT ... SELECT`. Per-file load drops from ~75-90s to ~5-6s
(~25-90x on the insert step), so a full baseline is ~2-3 hours serial.
Memory is unchanged (one file in flight at a time) and the row data is
identical. Adds a pyarrow dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
We had no way to size the loader's Slurm --mem (the first run guessed
100G). Log the process's peak resident set size after each file via
resource.getrusage, so a real run shows the high-water mark directly:

    loaded pubmed26n1334.xml.gz: 4989 articles, 0 deletions (peak RSS 0.8 GiB)

Peak RSS is driven by the largest single file, not the corpus, so this
reveals a tight --mem bound (observed <1 GiB for a 5k-article file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
scripts/benchmark_load.py times parsing vs insertion separately and
reports rows/s and peak RSS, for spotting load regressions and sizing
Slurm jobs. slurm/README.md documents how to run on the cluster, why
~16G --mem suffices (down from 100G), and how to monitor memory via the
per-file log line, seff, sstat/sacct, and /usr/bin/time -v. Updates
FUTURE.md: throughput item done, parallel Parquet-shard load deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download/journals/load/export steps are independent commands run in
sequence, but nothing caught a skipped or out-of-order step: export with
no journals silently emitted blank journal names, and export before load
wrote near-empty output.

Derive readiness from the database's own state (new status.py) rather
than a separate "step ran" flag, so a check can't disagree with the data:

- load: error if nothing downloaded (was a soft echo; now exits non-zero)
- export: error if no articles loaded; warn (and proceed) if files are
  downloaded but not yet loaded, or if the journal table is empty

Also decouple load from journals: load now loads article data only, and
journals is its own step (update still chains download -> journals ->
load). One job per command, with export's checks enforcing the ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read-only complement to the export/load prerequisite guards: report what
has been downloaded, loaded, and is ready to export, so you can tell at a
glance where the pipeline is without re-running a step.

`status.summarize` gathers the figures; download/load counts and recency
derive from the source_file registry (no duplicated truth). The one value
the data doesn't already carry is when `journals` last ran (its tables are
replaced wholesale), so load_journals now stamps it via db.record_run into
a new pipeline_run table; status reads it back. The command also prints an
at-a-glance Export verdict mirroring the export guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After each file, load_files now logs how many files have been processed
this run, how many remain, and a rolling ETA based on elapsed time — so
long Slurm jobs are easy to monitor without grepping timestamps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
load.py had private copies of these; pulling them into util.py lets
export.py reuse them without duplicating the logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The export command previously ran silently until it printed a final
file count, giving no indication of whether it was working or how
much memory to budget for future runs. JSON export now logs a
periodic x/y-documents-with-ETA progress line, Parquet export logs
per-table progress, and both report peak RSS on completion. The CLI
also echoes a start message and surfaces DuckDB's own progress bar
for long COPY queries under -v/--verbose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Compressing during the write avoids a second read/write pass over
multi-GB NDJSON shards. Output stays line-readable via zcat/gunzip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Existing CLI tests built the DB by calling load_file() directly, so
nothing exercised load's actual pystow directory scan (_local_files)
or the new --gzip export option. Adds a staged_download fixture that
lays fixture files out under a fake pystow baseline/updates layout,
and runs the CLI via subprocess rather than CliRunner: pubmed_downloader
fixes its pystow paths at import time from PYSTOW_HOME, so an
in-process CliRunner invocation would inherit whatever directory an
earlier-imported test already fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Initial implementation of pubmed2db, a Python CLI tool that downloads PubMed baseline/update XMLs, loads them into a full-version-history DuckDB schema, and exports the latest non-deleted article set to NDJSON (DocumentMetadataAPI fields) and Parquet.

Changes:

  • Added end-to-end pipeline modules (download → parse → load → export → status) plus a Click CLI wrapper.
  • Introduced a normalized DuckDB schema with a latest_article view for newest-version selection and deletion handling.
  • Added comprehensive pytest coverage with XML/journal fixtures, plus docs and Slurm run guidance.

Reviewed changes

Copilot reviewed 26 out of 30 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/pubmed2db/__init__.py Defines package version and module docstring.
src/pubmed2db/cli.py Click CLI implementing download, journals, load, export, update, status.
src/pubmed2db/db.py DuckDB connection + schema init + source-file registry helpers.
src/pubmed2db/download.py PubMed sync via pubmed-downloader with .md5 sidecar tracking and optional verification.
src/pubmed2db/export.py JSON (DocumentMetadataAPI fields, empty-string-not-null) and Parquet exports.
src/pubmed2db/load.py Normalized loading with full history, idempotent reloads, and journal overview parsing/loading.
src/pubmed2db/parse.py Self-driven XML iteration using _extract_article, plus raw PubDate + DeleteCitation capture.
src/pubmed2db/schema.sql Full normalized schema + latest_article view implementing newest-version selection with deletions.
src/pubmed2db/status.py Read-only pipeline readiness/state summarization derived from DB state.
src/pubmed2db/util.py Shared helpers for peak RSS and duration formatting.
tests/conftest.py Shared fixtures (gzipping XML fixtures, DuckDB connections, staged download layout).
tests/test_cli.py CLI end-to-end tests (export, status, error messaging, directory scan, gzip export).
tests/test_db_download.py Tests for filename ordering, registry upsert behavior, and MD5 parsing/helpers.
tests/test_export.py Tests for month normalization, JSON field contract, sharding, and Parquet latest/all behavior.
tests/test_journals.py Tests for journal overview parsing and journal load behavior.
tests/test_load.py Tests for full history retention, latest selection, deletion behavior, and MD5-triggered reloads.
tests/test_parse.py Tests for raw-date fidelity, rich extraction fields, and DeleteCitation capture.
tests/__init__.py Marks tests as a package.
tests/fixtures/pubmed25n0001.xml Baseline fixture XML used for parsing/load/export tests.
tests/fixtures/pubmed25n0002.xml Update fixture XML including a revised article and a DeleteCitation.
tests/fixtures/J_Entrez_sample.txt Sample NLM journal overview fixture for journal parser tests.
scripts/benchmark_load.py Script to benchmark parsing vs insertion throughput and peak RSS.
slurm/README.md Operational notes for running loads on Slurm (memory/time sizing, monitoring).
README.md Project documentation: purpose, pipeline, usage examples, and development notes.
CLAUDE.md Architecture/design orientation document for the repository.
FUTURE.md Deferred work items and known limitations.
pyproject.toml Project metadata, dependencies, and pytest configuration.
.gitignore Ignores downloaded data while keeping directory skeleton.
data/.gitkeep Keeps data/ directory present in the repository.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/pubmed2db/download.py
Comment on lines +54 to +58
def file_md5(path: Path) -> str:
"""Compute the MD5 hex digest of a local file."""
with path.open("rb") as fh:
return hashlib.file_digest(fh, "md5").hexdigest()

Comment thread src/pubmed2db/download.py
Comment on lines +83 to +97
file_name = url.rsplit("/", 1)[-1]
try:
response = requests.get(url + ".md5", timeout=60)
response.raise_for_status()
published_md5 = parse_md5_text(response.text)
_save_md5_sidecar(file_name, response.text)
except requests.RequestException as exc:
logger.warning("could not fetch md5 for %s: %s", file_name, exc)
published_md5 = None

prior = registry.get(file_name)
changed = prior is None or prior != published_md5

path = Path(ensure_module.ensure(url=url))

Comment thread src/pubmed2db/util.py
Comment on lines +5 to +17
import resource
import sys


def peak_rss_gib() -> float:
"""Peak resident set size of this process so far, in GiB.

``ru_maxrss`` is a high-water mark in bytes on macOS and KiB on Linux; we log
it after long-running steps so a Slurm run reveals how much ``--mem`` it
really needs.
"""
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return rss / 1024**3 if sys.platform == "darwin" else rss / 1024**2
load_files' progress log and the benchmark script both hand-rolled logic
already available; consolidate into shared helpers instead of copies.
gaurav and others added 11 commits July 4, 2026 19:02
latest_article is a window function over the full article table; both
export_json and export_parquet were re-evaluating it repeatedly (once for
the count, once per child table's EXISTS check). Snapshot it into a temp
table up front instead. Also use the shared eta_str helper here.
cli.py's export command and status command each re-implemented the same
blocked/warning verdict independently, risking drift; factor it into a
single status.export_readiness() both call. Also drop the six repeated
try/finally connection-close blocks in cli.py in favor of contextlib.closing,
and fold status.py's separate pending_file_count/table-count queries into
the existing aggregate queries.
A transient network error fetching a file's .md5 sidecar set
published_md5 to None, which register_source_file's ON CONFLICT then
wrote through unconditionally, permanently wiping a known-good
checksum and spuriously marking the file changed (triggering a
needless reload). Fall back to the previously registered checksum
instead so a fetch failure is a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
export_json opened all `shards` files eagerly regardless of how many
documents existed, leaving unreported empty shard files on disk when
the dataset was smaller than the shard count. Now only min(shards,
total) files are opened and returned.

_copy_parquet interpolated the output path into a quoted SQL string
literal unescaped, so a path containing a single quote broke the COPY
statement. Escape embedded quotes before interpolating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
update only loaded the files returned by its own sync() call, unlike
the standalone load command which rescans the whole local directory
via _local_files(). Files downloaded outside that run (or beyond a
--limit) were silently skipped. update now scans locally like load
does, and accepts the same --baseline/--updates/--verify flags as
download instead of hardcoding them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A single PubMed XML file can contain the same PMID twice (e.g.
citation-correction artifacts). Both rows were inserted with an
identical (pmid, source_file), so article's documented per-file
identity didn't actually hold and latest_article's row_number()
ranking had no way to break the tie deterministically. Keep only the
last occurrence per file so the identity holds and no tiebreaker is
needed; n_articles now reflects the deduped count actually stored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "downloaded but not (re)loaded" rule was implemented three times:
as Python in load.needs_load, and as two separate (but supposedly
identical) SQL fragments in status.pending_file_count and inline again
in status.summarize. A future change to the rule risked being applied
to only some of the three copies, letting status and load/export
silently disagree about what's pending. Introduce
status.NEEDS_LOAD_SQL as the single source of truth; summarize() now
calls pending_file_count() instead of re-deriving it, and
load.needs_load() queries the same SQL fragment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Articles failing _extract_article were skipped with only a per-article
log warning — no counter, no DB record, nothing visible in status or
export. Track n_failed on ParsedFile, log a per-file summary count,
and include it in load_file's log line so a persistently-failing PMID
is no longer invisible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
load_journals ran DELETE FROM journal / journal_issn and the
subsequent executemany inserts as separate autocommitted statements,
unlike load_parsed's explicit transaction. A crash or malformed record
partway through left the journal table empty, with export_readiness
only warning (not blocking), so an export could silently proceed with
blank journal names. Wrap the whole refresh in BEGIN/COMMIT/ROLLBACK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
load.needs_load stopped using it once it moved to a direct
NEEDS_LOAD_SQL query; the function was only exercised by its own
test. Rewrite that test to query source_file directly and drop the
function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
download and update repeated the identical
--baseline/--updates/--limit/--verify click.option stack verbatim.
Factor them into a _sync_options decorator applied to both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 30 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (3)

src/pubmed2db/download.py:57

  • hashlib.file_digest() is only available in newer Python versions (3.11+). Since this project declares requires-python = ">=3.10", this implementation will raise AttributeError on Python 3.10 when --verify is enabled.
def file_md5(path: Path) -> str:
    """Compute the MD5 hex digest of a local file."""
    with path.open("rb") as fh:
        return hashlib.file_digest(fh, "md5").hexdigest()

src/pubmed2db/download.py:89

  • If the .md5 sidecar format changes (or is otherwise unparsable), parse_md5_text() returns None, which currently makes changed = prior != published_md5 flip to True and can spuriously bump downloaded_at (triggering unnecessary reloads). Treat an unparsable checksum like a transient failure and keep the prior checksum instead.
            response = requests.get(url + ".md5", timeout=60)
            response.raise_for_status()
            published_md5 = parse_md5_text(response.text)
            _save_md5_sidecar(file_name, response.text)

src/pubmed2db/download.py:103

  • When --no-verify is used, a changed published checksum does not currently force a re-download. Because pubmed_downloader is documented here as “skip-by-name”, ensure() may keep the old local file even though the remote content changed, and the pipeline would reload stale bytes. A checksum change should trigger a redownload regardless of --verify.
        changed = prior is None or prior != published_md5

        path = Path(ensure_module.ensure(url=url))

        if verify and published_md5 is not None:
            actual = file_md5(path)

Comment thread src/pubmed2db/download.py
Comment on lines +64 to +74
def _sync_kind(
con: duckdb.DuckDBPyConnection,
*,
kind: str,
base_url: str,
list_cache: Path,
ensure_module,
registry: dict[str, dict],
limit: int | None,
verify: bool,
) -> list[tuple[Path, str]]:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants