Initial implementation: download, store, and export PubMed abstracts - #1
Open
gaurav wants to merge 38 commits into
Open
Initial implementation: download, store, and export PubMed abstracts#1gaurav wants to merge 38 commits into
gaurav wants to merge 38 commits into
Conversation
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>
There was a problem hiding this comment.
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_articleview 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 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 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 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.
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>
There was a problem hiding this comment.
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 declaresrequires-python = ">=3.10", this implementation will raiseAttributeErroron Python 3.10 when--verifyis 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
.md5sidecar format changes (or is otherwise unparsable),parse_md5_text()returnsNone, which currently makeschanged = prior != published_md5flip toTrueand can spuriously bumpdownloaded_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-verifyis used, a changed published checksum does not currently force a re-download. Becausepubmed_downloaderis 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 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]]: |
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.
WIP
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:cthoyt/pubmed-downloaderfor bulk baseline/update fetching; adds.md5sidecar tracking so new/changed checksums drive incremental reloads._extract_articlefor the rich record while additionally capturing rawPubDatecomponents (full date fidelity) and<DeleteCitation>PMIDs.source_fileprovenance;latest_articleview selects the newest non-deleted version per PMID. Journal names come from the NLM Catalog.pub_monthas 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
pubmed-downloaderas-is (no upstream changes) for download + parse; DuckDB is our store (we don't use its JSONL cache).See
CLAUDE.mdfor architecture andFUTURE.mdfor 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 realJ_Entrez.txt(itsJournalmodel requiresstart_year/end_yearthe file omits), so we parse the overview file ourselves. Tracked inFUTURE.mdto revert once fixed upstream.Verification
🤖 Generated with Claude Code