Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions src/linkml_reference_validator/etl/sources/pmid.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,10 @@ def fetch(
year = str(pub_date)[:4] if pub_date else ""
doi = str(record_dict.get("DOI", "")) if record_dict.get("DOI") else ""

abstract = self._fetch_abstract(pmid, config)
# A single efetch of the article XML backs the abstract, MeSH terms,
# and publication types, so we don't round-trip to NCBI three times.
article_xml = self._fetch_pubmed_xml(pmid, config)
abstract = self._parse_abstract(article_xml) if article_xml else None
keywords = self._parse_mesh_terms(article_xml) if article_xml else None
publication_types = (
self._parse_publication_types(article_xml) if article_xml else None
Expand Down Expand Up @@ -188,29 +190,47 @@ def _parse_authors(self, author_list: list) -> list[str]:
"""
return [str(author) for author in author_list if author]

def _fetch_abstract(
self, pmid: str, config: ReferenceValidationConfig
) -> Optional[str]:
"""Fetch abstract for a PMID.
def _parse_abstract(self, soup: BeautifulSoup) -> Optional[str]:
"""Parse the abstract from a PubMed article XML document.

Reconstructs the abstract prose from ``Abstract/AbstractText`` nodes.
Structured abstracts label each section (e.g. ``Label="METHODS"``);
the label is preserved as a ``"METHODS:"`` prefix and sections are
joined by blank lines, mirroring how PubMed renders them as text.

Args:
pmid: PubMed ID
config: Configuration for rate limiting
soup: Parsed PubMed article XML

Returns:
Abstract text if available
Abstract text if available, otherwise None

Examples:
>>> from bs4 import BeautifulSoup
>>> xml = '''<Abstract>
... <AbstractText Label="METHODS">We ran a trial.</AbstractText>
... <AbstractText Label="RESULTS">It worked.</AbstractText>
... </Abstract>'''
>>> PMIDSource()._parse_abstract(BeautifulSoup(xml, "xml"))
'METHODS: We ran a trial.\\n\\nRESULTS: It worked.'
>>> xml = '<Abstract><AbstractText>A summary.</AbstractText></Abstract>'
>>> PMIDSource()._parse_abstract(BeautifulSoup(xml, "xml"))
'A summary.'
"""
time.sleep(config.rate_limit_delay)
abstract = soup.find("Abstract")

handle = Entrez.efetch(db="pubmed", id=pmid,
rettype="abstract", retmode="text")
abstract_text = handle.read()
handle.close()
if not abstract:
return None

if abstract_text and len(abstract_text) > 50:
return str(abstract_text)
sections = []
for node in abstract.find_all("AbstractText"):
text = node.get_text().strip()
if not text:
continue
label = node.get("Label")
sections.append(f"{label}: {text}" if label else text)

return None
joined = "\n\n".join(sections)
return joined if joined else None

def _fetch_pubmed_xml(
self, pmid: str, config: ReferenceValidationConfig
Expand Down
69 changes: 58 additions & 11 deletions tests/test_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,10 @@ def test_get_pmcid_handles_entrez_error(
<MedlineCitation>
<Article>
<ArticleTitle>An illustrative case</ArticleTitle>
<Abstract>
<AbstractText Label="METHODS">We describe the case.</AbstractText>
<AbstractText Label="RESULTS">The patient recovered.</AbstractText>
</Abstract>
<PublicationTypeList>
<PublicationType UI="D016428">Journal Article</PublicationType>
<PublicationType UI="D002363">Case Reports</PublicationType>
Expand Down Expand Up @@ -383,13 +387,56 @@ def test_parse_mesh_terms_empty_list(self, source):

assert source._parse_mesh_terms(soup) is None

# --- Abstract parsing (issue #56 note 2: fold into single XML fetch) ---

def test_parse_abstract_structured(self, source):
"""Structured abstracts should keep their section labels."""
from bs4 import BeautifulSoup

soup = BeautifulSoup(self._ARTICLE_XML, "xml")

assert source._parse_abstract(soup) == (
"METHODS: We describe the case.\n\nRESULTS: The patient recovered."
)

def test_parse_abstract_unstructured(self, source):
"""A single unlabelled AbstractText should return its prose verbatim."""
from bs4 import BeautifulSoup

soup = BeautifulSoup(
"<Abstract><AbstractText>Just one paragraph.</AbstractText></Abstract>",
"xml",
)

assert source._parse_abstract(soup) == "Just one paragraph."

def test_parse_abstract_absent(self, source):
"""Should return None when there is no Abstract element."""
from bs4 import BeautifulSoup

soup = BeautifulSoup(
"<PubmedArticleSet><PubmedArticle/></PubmedArticleSet>", "xml"
)

assert source._parse_abstract(soup) is None

def test_parse_abstract_empty(self, source):
"""An Abstract with only empty AbstractText should yield None."""
from bs4 import BeautifulSoup

soup = BeautifulSoup(
"<Abstract><AbstractText></AbstractText></Abstract>", "xml"
)

assert source._parse_abstract(soup) is None

@patch("linkml_reference_validator.etl.sources.pmid.Entrez.efetch")
@patch("linkml_reference_validator.etl.sources.pmid.Entrez.esummary")
@patch("linkml_reference_validator.etl.sources.pmid.Entrez.read")
def test_fetch_populates_publication_types(
def test_fetch_uses_single_efetch(
self, mock_read, mock_esummary, mock_efetch, source, config
):
"""fetch() should surface publication types on the ReferenceContent."""
"""fetch() should derive abstract, MeSH, and pub types from one efetch."""
mock_read.return_value = [
{
"Title": "An illustrative case",
Expand All @@ -400,19 +447,19 @@ def test_fetch_populates_publication_types(
}
]

def _efetch(*args, **kwargs):
handle = MagicMock()
if kwargs.get("rettype") == "abstract":
handle.read.return_value = "x" * 100
else:
handle.read.return_value = self._ARTICLE_XML
return handle

mock_efetch.side_effect = _efetch
handle = MagicMock()
handle.read.return_value = self._ARTICLE_XML
mock_efetch.return_value = handle

ref = source.fetch("12345678", config)

# A single article-XML fetch backs all three fields — no second call.
assert mock_efetch.call_count == 1
assert ref is not None
assert ref.content == (
"METHODS: We describe the case.\n\nRESULTS: The patient recovered."
)
assert ref.content_type == "abstract_only"
assert ref.publication_types == [
"Journal Article",
"Case Reports",
Expand Down