Skip to content

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

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

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

Conversation

@gaurav

@gaurav gaurav commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

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.

Step ordering is enforced from DB state rather than a run flag: load errors if nothing is downloaded, export errors if nothing is loaded and warns about unloaded files or an empty journal table, and the read-only status command reports the same signals.

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.
  • Columnar bulk load: each file's rows go in as an Arrow table via INSERT ... SELECT, ~25–90× faster than the original row-by-row executemany (~20 min/file → ~5–6 s/file).

Operations

  • Everything runs from the repo with uv run pubmed2db …; nothing is installed.
  • Group-level --threads / --temp-dir (env: PUBMED2DB_THREADS, PUBMED2DB_DUCKDB_TEMP_DIR) cap DuckDB's thread pool and set its spill directory, since DuckDB otherwise sizes its pool from the node's cores rather than the Slurm allocation.
  • slurm/README.md documents sizing for both jobs: load needs ~16 GB (memory is bounded by the largest single file), export needs ~256 GB (whole-corpus snapshot + sort).
  • The README documents why re-running download/load after a gap can't duplicate data, and the one case that costs real time: a new baseline year is a full re-parse that stores a second version of every PMID.

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 via PR cthoyt/pubmed-downloader#16

Verification

  • 43 tests pass (no network): parse fidelity, latest-version/delete logic, idempotent + MD5-change reloads, journal parsing, JSON spec fields, Parquet filtering, DuckDB tuning options, CLI end-to-end.
  • Full-scale run on RENCI's cluster: the complete corpus exported as 40,901,984 documents across 16 NDJSON shards in 23m13s (≈30k documents/s), peak RSS 201.1 GiB under --mem=256G --cpus-per-task 8.
  • Earlier single-file live run: MD5-verified download → 16,664 articles + 56 deletions → 41,923 journals → 16,664 docs across even NDJSON shards (zero nulls, journal names/abbrevs resolved, months normalized) and 15 consistent Parquet files.

TODO (undecided: fix here or file)

  • Confirm the pub_year backfill at corpus scale after the rebuild. The
    fix is verified end-to-end on one real baseline file (pubmed26n0005.xml.gz:
    3,625 of 30,000 records, 127 distinct MedlineDate shapes, all recovering a
    year). The corpus-wide check is that validate's core-fields drops from 20
    sampled mismatches to ~2 on the next export.
  • Investigate the two residual mismatches that the backfill does not
    explain: issue exported blank vs Entrez "Suppl" (PMID 10137601), and
    article_title blank vs "[Not Available]." (PMID 28972331). Both are
    "exported blank where Entrez has a value", so neither is corrupt data — but
    they may be a third parse gap. Read the archival XML before assuming a cause;
    efetch alone points at the wrong layer (see CLAUDE.md).
  • Decide load --force vs a fresh rebuild, and write it into the README's
    "Re-running after a gap".
    schema.sql only ever adds tables, so a forced
    reload refreshes article_id's rows but leaves the dropped
    reference_citation table sitting there. Rebuilding is probably the simpler
    answer; it is currently only noted in FUTURE.md.
  • Test download --limit (newest-N) and the --verify gating. Two
    one-line behaviour changes with no coverage, because _sync_kind would need
    _ensure_urls, requests.get and ensure_module.ensure all mocked.
  • Already filed: Consider dropping the global sort in the JSON export #8 — drop the JSON export's global ORDER BY la.pmid in
    favour of pmid % shards. Measure first, since restricting the abstract
    aggregation to the latest snapshot already cut into the same peak RSS.

🤖 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.
Answers the recurring question of whether re-running `load` can duplicate
data (it can't: MD5-gated downloaded_at, per-source_file delete before
insert, and (pmid, source_file) identity resolved by latest_article), and
flags the case that actually costs something — a new baseline year is a
full re-parse that stores a second version of every PMID.

Co-Authored-By: Claude Opus 5 (1M context) <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 no new comments.

Comments suppressed due to low confidence (4)

src/pubmed2db/download.py:57

  • file_md5() uses hashlib.file_digest(), which is only available in Python 3.11+. Since pyproject.toml declares requires-python = ">=3.10", this will raise AttributeError on Python 3.10 and break download --verify (and test_file_md5). Implement MD5 hashing in a way that works on 3.10+ (e.g., chunked hashlib.md5() updates), or bump the project’s minimum Python to 3.11.
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:73

  • Type annotation for registry is incorrect: sync() builds it as {file_name: published_md5} (a str|None), but _sync_kind() declares registry: dict[str, dict]. This makes prior's type misleading and can hide real type errors.
    ensure_module,
    registry: dict[str, dict],
    limit: int | None,
    verify: bool,

pyproject.toml:17

  • pydantic is declared as a direct dependency but does not appear to be used anywhere in src/ or tests/. Keeping it increases install size and dependency surface area unnecessarily; consider removing it unless it’s needed for upcoming work.
    "pyarrow>=14",
    "click>=8.1",
    "pydantic>=2",
    "requests>=2.31",
    "tqdm>=4.66",
    "lxml>=5",

pyproject.toml:10

  • The code imports and uses pubmed_downloader private APIs (pubmed_downloader.api._extract_article in src/pubmed2db/parse.py and pubmed_downloader.api._ensure_urls in src/pubmed2db/download.py). With an unconstrained pubmed-downloader>=0.0.14 requirement, a future upstream release can break pubmed2db without any changes here. Consider pinning pubmed-downloader to the exact tested version (or at least adding an upper bound) until these private API usages are replaced with stable/public ones.
dependencies = [
    "pubmed-downloader>=0.0.14",
    "duckdb>=1.1",

gaurav and others added 5 commits July 30, 2026 01:22
export is a whole-corpus job (latest_article snapshot + abstract
string_agg + sort by PMID), so its memory scales with the database, not
the largest input file: 199.6 GiB peak vs the loader's ~0.8. Records the
sizing, why --shards doesn't help, and that spilling to temp_directory
isn't reachable without a code change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l dir

DuckDB sizes its thread pool from the machine's core count, so on a
cluster it oversubscribes an allocation smaller than the node; there was
also no way to move its spill directory off the database volume. Both are
now group-level CLI options (with PUBMED2DB_THREADS /
PUBMED2DB_DUCKDB_TEMP_DIR env vars) passed to duckdb.connect(config=...).

Also documents that --shards is not a parallelism knob: all shards are
written by one loop, so CPUs help DuckDB's query, not the writer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A full JSON export on ht1 finished in ~23 minutes (~30k documents/s), so
the "a few hours" estimate was wrong and --time=08:00:00 was eight times
the need; memory, not time, is what makes this job need a big node.

Also drops the seff instructions — it isn't installed on ht1 — in favor
of sacct, /usr/bin/time -v, and the progress logging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
23m13s (01:25:01 to 01:48:14) at --mem=256G --cpus-per-task 8, and
notably without --threads: DuckDB sized its own pool and the run was
still fast, so --threads is insurance against a busy node rather than
something the export needs. Tones the guidance down to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TL;DR still asked for 8 hours and --threads 8, which the "Running
export" section had already walked back to 2 hours and threads-optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the JSON export has been run at scale; the expectation that Parquet
is lighter is reasoning about COPY ... TO, not a measurement, and the
docs shouldn't read as though both were verified.

Co-Authored-By: Claude Opus 5 (1M context) <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 no new comments.

Suppressed comments (2)

src/pubmed2db/download.py:73

  • Type hint for registry is incorrect: sync() builds it as dict[str, str | None] (file_name -> published_md5), but _sync_kind() annotates it as dict[str, dict]. This makes type checking misleading and can hide real issues around the MD5 comparison logic.
    list_cache: Path,
    ensure_module,
    registry: dict[str, dict],
    limit: int | None,
    verify: bool,

src/pubmed2db/download.py:57

  • file_md5() uses hashlib.file_digest, which is not available in Python 3.10. Since pyproject.toml declares requires-python = ">=3.10", this will raise AttributeError on the minimum supported version and break download --verify (and its tests). Use a 3.10-compatible MD5 implementation (e.g., incremental hashlib.md5().update() loop), or bump the minimum Python version.
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 FUTURE.md Outdated
gaurav and others added 6 commits August 4, 2026 02:29
download.file_md5 uses hashlib.file_digest, added in 3.11, so the declared
>=3.10 floor promised an interpreter where `download --verify` (on by
default) raises AttributeError.

Also bound pubmed-downloader below 0.1: we call its private APIs
(_extract_article, _ensure_urls), so any 0.0.x release can break the
pipeline without a major-version signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two upstream bugs in pubmed-downloader (<=0.0.14) silently corrupted what we
store, both caused by looking for <ReferenceList> in the wrong place:

- _extract_article searches medline_citation for .//ReferenceList/Reference,
  but PubMed nests <ReferenceList> under <PubmedData>. Article.cites_pubmed_ids
  is therefore always empty on real data, and reference_citation would never
  have received a row.

- Article IDs are collected as pubmed_data.findall(".//ArticleIdList/ArticleId"),
  and that .// descends into that same <ReferenceList> -- so every cited
  reference's DOI/PMID was attributed to the citing article. article_id was
  gaining wrong rows in proportion to reference count, and exporting them.

parse.py already exists to capture what upstream drops, so both go there:
_cited_pmids searches the whole PubmedArticle element (matching either
placement) and _article_ids uses the direct PubmedData/ArticleIdList/ArticleId
path, dropping the redundant `pubmed` self-ID.

The fixture gains a ReferenceList in PubMed's real position, and both new
tests assert the *upstream* behaviour as well as ours, so they fail once
cthoyt fixes this and the workarounds can be deleted.

reference_citation.cited_pmid becomes BIGINT to match article.pmid, so
citations join back without a cast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full baseline is ~1,300 files. parse_file contains per-article ValueError
and KeyError, but anything else -- a truncated .gz, an XMLSyntaxError -- came
out of load_files and killed the whole job, which on Slurm means losing hours
and restarting by hand.

Each file already loads in its own transaction, so a failure leaves no partial
rows and no processed_at watermark; a later run retries it. Log it, count it,
and keep going. load_files now returns (n_loaded, failed_file_names) so the
CLI can name the failures and exit non-zero rather than folding them into a
normal-looking "Loaded N of M" line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the twelve versioned tables never received a row in any test:
reference_citation, grant_, mesh_qualifier, and the Collective branch of
_article_rows. The fixture had no <ReferenceList>, <GrantList>,
<QualifierName>, or <CollectiveName> -- which is how the two upstream
reference bugs went unnoticed.

Adds a grant, a MeSH qualifier, an ORCID, and a collective author to the
fixture, plus a test that a corrupt file is skipped without leaving rows or a
watermark behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
download: --limit sliced urls[:limit], the *oldest* N, while the CLI help,
README and CLAUDE.md all promised the newest N -- so testing update-file
handling silently got baseline files instead. Takes the tail now.

download: --verify re-hashed every local .xml.gz on every sync, tens of GiB of
I/O to re-confirm immutable files. Hash only what is new or whose published
checksum moved; corruption at download time stays covered, so verification can
stay on by default.

export: the JSON export's abstract CTE grouped the entire abstract_text table,
including superseded versions, then discarded them in the join -- the join sits
on the outer side's right, so the optimizer cannot push the restriction in.
Restrict to _latest_snapshot before aggregating, as export_parquet already did.

export: month_to_abbrev read calendar.month_abbr, which is strftime('%b') under
LC_TIME. Nothing calls setlocale today, but any dependency that did would
localize a spec-defined output field. Frozen tuple instead.

export: warn when a format-specific flag is passed to the other format rather
than silently ignoring it, which is expensive to discover after a 20-minute run.

status: the baseline/update breakdown counted known files while being printed
as a breakdown of downloaded files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav and others added 5 commits August 4, 2026 02:47
Nothing has been filed against cthoyt/pubmed-downloader for either bug, and
nothing should be yet: both were found while writing a fixture, so the evidence
is a synthetic XML file and the DTD rather than real data.

Records what needs answering first -- confirming <ReferenceList> placement
across real baseline years, quantifying the article_id contamination, auditing
upstream's other .// selectors for the same over-reach, and establishing the
affected version range -- plus the separate question of what to do about
databases already carrying the bad rows, which no incremental run corrects.

Also points at issue #8 for the JSON export's global sort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The add-doi-and-pmcids branch found the article_id bug independently and
measured it against real records, which answers two of the open investigation
items: PMID:41136637 contributed 426 cited references' DOIs alongside its own,
and reference_citation came back with 0 rows from 14,201 real articles whose
records carry hundreds of references.

Marks those two done and narrows the rest: the XPath audit is partly done (it
is what turned up cites_pubmed_ids), leaving History and the
abstract/MeSH/author selectors unchecked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The citation graph is not wanted. One real article carries ~444 references, so
at corpus scale this would have been the largest table in the database, loaded
and Parquet-exported on every run, for data no consumer reads.

Removes the table from schema.sql, load._VERSIONED_TABLES and
export._VERSIONED_CHILDREN, and stops building its rows.

parse._cited_pmids stays, uncalled, with re-enabling instructions in its
docstring. A CLI or config flag was the other option and would have cost more
than it guarded: threading a parameter through load_files -> load_file ->
load_parsed -> _article_rows, conditional DDL, and a second tested code path.
Parking the function costs nothing at runtime -- parse_file no longer calls it,
so we don't pay an XPath search per article for a discarded result -- and keeps
a working counter-example to the upstream cites_pubmed_ids bug that FUTURE.md
still tracks for reporting.

test_no_reference_citation_table keeps the table from creeping back;
test_cited_pmids_is_parked_but_works exercises the parked function directly.

Note for existing databases: schema.sql only ever adds tables, so a database
built before this keeps a populated reference_citation. Rebuilding clears it;
`DROP TABLE reference_citation` does too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full-corpus validate run sampled 240 exported records and found 20 core-field
disagreements with Entrez. 18 were pub_year: the export shipped blank where
Entrez had a year. Spot-checking those PMIDs (e.g. 152567) shows PubDate values
that carry no <Year> element at all -- a season or a range that PubMed puts
wholly inside <MedlineDate> ("1978 Jul-Aug", "1998 Spring", "1998 Dec-1999
Jan"). We store that verbatim for fidelity, and the export never looked at it.

The leading 4-digit year is unambiguous in every one of those shapes, so
_document now falls back to it. The database is untouched -- medline_date keeps
its raw value and DocumentMetadataAPI semantics stay confined to the export --
so this needs a re-export but no reload.

pub_month and pub_day stay empty on purpose. A range has no single month, and
inventing "Jul" for "1978 Jul-Aug" would replace an honest blank with a wrong
value in a field consumers are entitled to trust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backfill was reasoned from efetch output and the DTD, which is not the same
as checking what we actually parse. Downloaded pubmed26n0005.xml.gz and looked:
PMID 152567 really is <MedlineDate>1978 Jul-Aug</MedlineDate> with no <Year>
element, so parse yields pub_year=None and the export shipped a blank.

Loading and exporting that file end-to-end confirms the fix: PMID 152567 now
exports pub_year "1978", matching what Entrez reports for it.

Scale is larger than the sampled validation suggested. 3,625 of the file's
30,000 records (12%) carry a MedlineDate with no Year, across 127 distinct date
strings. Every one yields a year, and in no case does the leading year disagree
with another 4-digit run in the string -- so the regex is not just adequate on
the fixtures, it is adequate on real data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav added a commit that referenced this pull request Aug 4, 2026
Brings PR #1 in now that there is no further value in validating the old export
separately. Two things the merge broke, both real rather than cosmetic:

EXPECTED_FIELDS called export._document with a hardcoded 9-value placeholder
row. PR #1's pub_year backfill selects medline_date, making the row 11 wide, so
validate failed at import. The arity is now discovered by widening the
placeholder until _document accepts it -- the field *names* were already derived
from the exporter to stop them drifting, but the arity was not, and it is the
part that changes whenever the export query gains a column.

The export now recovers a year from a free-text MedlineDate, while validate read
efetch's <Year> raw. efetch usually renders those records as <Year>+<Season>,
but not always, and when it returns the archival form every such record would
have read as a pub_year mismatch against an export that recovered it. validate
now applies the exporter's own recovery to the efetch side, the same way it
already imports month_to_abbrev -- normalization must be applied to both sides
or the comparison is not like-for-like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav added a commit that referenced this pull request Aug 4, 2026
Now that PR #2 has merged PR #1, this branch picks both up and its diff
collapses back to the export-side work it is actually about.

Conflict resolutions, all additive rather than either/or:

- export.py: _LATEST_METADATA_SQL selects both `la.medline_date` (for the
  pub_year backfill) and `ids.identifiers`, and _document unpacks all twelve.
  Verified the two coexist: PMID 1003 exports pub_year "1998" recovered from
  "1998 Spring" with identifiers ["PMID:1003"], while PMID 1001 keeps its DOI
  and PMCID alongside a real pub_year.
- validate.py: keeps ID_PREFIXES and adds _year_from_medline_date, so both of
  the exporter's normalizations are applied to the efetch side. This branch had
  independently bumped EXPECTED_FIELDS' placeholder row to a hardcoded 10; the
  derived version supersedes it, which is the point -- `identifiers` and
  `medline_date` each widened that row once already.
- FUTURE.md/CLAUDE.md: both sides' notes kept. The ELocationID follow-up and the
  identifiers design note are unique to this branch; the completed MedlineDate
  entry and the efetch-is-a-rendering warning come from the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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