bioconverters

 1__docformat__ = "google"
 2
 3from .pmc_types import PMCArticle
 4from .pmcxml import parse_pmcxml, pmcxml2bioc, pmcxml2txt
 5from .pubmed_types import (
 6    Chemical,
 7    MeshHeading,
 8    MeshQualifier,
 9    PublicationType,
10    PubMedArticle,
11    SupplementaryMeshConcept,
12)
13from .pubmedxml import parse_pubmedxml, pubmedxml2bioc, pubmedxml2txt
14
15__all__ = [
16    "parse_pmcxml",
17    "pmcxml2bioc",
18    "pmcxml2txt",
19    "PMCArticle",
20    "parse_pubmedxml",
21    "pubmedxml2bioc",
22    "pubmedxml2txt",
23    "PubMedArticle",
24    "Chemical",
25    "MeshHeading",
26    "MeshQualifier",
27    "SupplementaryMeshConcept",
28    "PublicationType",
29]
def parse_pmcxml( source: str | TextIO, return_xml: bool = False, keep_tags=set(), trim_buggy_sentences: bool = True, inject_citations: bool = False, clean_numeric_citations: bool = True, clean_xrefs_in_brackets: bool = True, clear_empty_brackets: bool = True, fix_exponentials: bool = True) -> Iterable[PMCArticle]:
344def parse_pmcxml(
345    source: Union[str, TextIO],
346    return_xml: bool = False,
347    keep_tags=set(),
348    trim_buggy_sentences: bool = True,
349    inject_citations: bool = False,
350    clean_numeric_citations: bool = True,
351    clean_xrefs_in_brackets: bool = True,
352    clear_empty_brackets: bool = True,
353    fix_exponentials: bool = True,
354) -> Iterable[PMCArticle]:
355    """
356    Parse a PMC XML file into a series of PMCArticle objects (one per article/sub-article).
357
358    Args:
359        source: The text or file handle containing the PMC XML
360        return_xml: return each passage's text as a marked-up XML string if True, or as
361            plain, unescaped text with any markup stripped if False (default).
362        keep_tags: with return_xml=True, tags whose markup is preserved inline in each
363            passage's text (e.g. "sup", "italic") - has no effect when return_xml=False.
364            Defaults to an empty set (no markup kept). Pass `pmc_constants.PMC_KEEP_TAGS`
365            for the common formatting tags (sup, sub, italic, bold, underline, monospace,
366            sc, overline, strike).
367        trim_buggy_sentences: trim overly long, unbroken runs of text to a maximum length,
368            to avoid issues with buggy sentences in some PMC articles.
369        inject_citations: resolve each in-text bibr citation's pmid/doi and retag it to
370            `<citation>`, kept in the output instead of dropped. Must not be combined with
371            clean_numeric_citations=True - injection enriches bibr citations,
372            clean_numeric_citations deletes them, so having both on is almost certainly a mistake.
373        clean_numeric_citations: drop a numeric bibr citation marker outright, e.g. "1", "[1,2]",
374            regardless of context - this is what catches a citation glued directly onto a
375            word with no separating space at all.
376        clean_xrefs_in_brackets: drop bracket-wrapped xref clutter, e.g. "(Table 1)".
377        clear_empty_brackets: remove any "(...)"/"[...]"/"{...}" left containing no word
378            characters, e.g. from clean_numeric_citations/clean_xrefs_in_brackets, or from an
379            unrelated ignore_tag (like ext-link) that happened to be parenthesised.
380        fix_exponentials: with return_xml=False, recover a digit-preceded numeric `<sup>`
381            as "^N" instead of losing it to plain concatenation, e.g. `"10<sup>8</sup>"` ->
382            "10^8".
383
384    These defaults match pmcxml2bioc/pmcxml2txt, so behavior is consistent regardless of
385    which entry point is used.
386    """
387    assert not (inject_citations and clean_numeric_citations), (
388        "inject_citations and clean_numeric_citations can't both be True - injection enriches bibr "
389        "citations, clean_numeric_citations deletes them. Pass clean_numeric_citations=False."
390    )
391
392    source = _apply_pmc_xlink_fix(source)
393
394    # Skip to the article element in the file
395    for event, elem in etree.iterparse(source, events=("start", "end", "start-ns", "end-ns")):
396        if event == "end" and elem.tag == "article":
397            if inject_citations:
398                citation_lookup = _build_citation_lookup(elem)
399                _inject_citations(elem, citation_lookup)
400
401            meta = _get_meta_info_for_pmc_article(elem)
402
403            # We're going to process the main article along with any subarticles
404            # And if any of the subarticles have distinguishing IDs (e.g. PMID), then
405            # that'll be used, otherwise the parent article's metadata will be used
406            subarticles = [elem] + elem.findall("./sub-article")
407
408            for article_elem in subarticles:
409                if article_elem is elem:
410                    # This is the main parent article. Just use its metadata
411                    sub_meta = meta
412                else:
413                    # Check if this subarticle has any distinguishing IDs and use them instead
414                    sub_meta = _get_meta_info_for_pmc_article(article_elem)
415                    if not (sub_meta.pmid or sub_meta.pmcid or sub_meta.doi):
416                        sub_meta.pmid = meta.pmid
417                        sub_meta.pmcid = meta.pmcid
418                        sub_meta.doi = meta.doi
419                    if sub_meta.pub_year is None:
420                        sub_meta.pub_year = meta.pub_year
421                        sub_meta.pub_month = meta.pub_month
422                        sub_meta.pub_day = meta.pub_day
423                    if not sub_meta.journal:
424                        sub_meta.journal = meta.journal
425                        sub_meta.journal_iso = meta.journal_iso
426
427                content = _extract_article_content(
428                    article_elem,
429                    keep_tags,
430                    return_xml,
431                    trim_buggy_sentences,
432                    inject_citations,
433                    clean_numeric_citations,
434                    clean_xrefs_in_brackets,
435                    clear_empty_brackets,
436                    fix_exponentials,
437                )
438
439                yield PMCArticle(
440                    pmid=sub_meta.pmid,
441                    pmcid=sub_meta.pmcid,
442                    doi=sub_meta.doi,
443                    pub_year=sub_meta.pub_year,
444                    pub_month=sub_meta.pub_month,
445                    pub_day=sub_meta.pub_day,
446                    journal=sub_meta.journal,
447                    journal_iso=sub_meta.journal_iso,
448                    **content,
449                )
450
451            # Less important here (compared to abstracts) as each article file is not too big
452            elem.clear()

Parse a PMC XML file into a series of PMCArticle objects (one per article/sub-article).

Arguments:
  • source: The text or file handle containing the PMC XML
  • return_xml: return each passage's text as a marked-up XML string if True, or as plain, unescaped text with any markup stripped if False (default).
  • keep_tags: with return_xml=True, tags whose markup is preserved inline in each passage's text (e.g. "sup", "italic") - has no effect when return_xml=False. Defaults to an empty set (no markup kept). Pass pmc_constants.PMC_KEEP_TAGS for the common formatting tags (sup, sub, italic, bold, underline, monospace, sc, overline, strike).
  • trim_buggy_sentences: trim overly long, unbroken runs of text to a maximum length, to avoid issues with buggy sentences in some PMC articles.
  • inject_citations: resolve each in-text bibr citation's pmid/doi and retag it to <citation>, kept in the output instead of dropped. Must not be combined with clean_numeric_citations=True - injection enriches bibr citations, clean_numeric_citations deletes them, so having both on is almost certainly a mistake.
  • clean_numeric_citations: drop a numeric bibr citation marker outright, e.g. "1", "[1,2]", regardless of context - this is what catches a citation glued directly onto a word with no separating space at all.
  • clean_xrefs_in_brackets: drop bracket-wrapped xref clutter, e.g. "(Table 1)".
  • clear_empty_brackets: remove any "(...)"/"[...]"/"{...}" left containing no word characters, e.g. from clean_numeric_citations/clean_xrefs_in_brackets, or from an unrelated ignore_tag (like ext-link) that happened to be parenthesised.
  • fix_exponentials: with return_xml=False, recover a digit-preceded numeric <sup> as "^N" instead of losing it to plain concatenation, e.g. "10<sup>8</sup>" -> "10^8".

These defaults match pmcxml2bioc/pmcxml2txt, so behavior is consistent regardless of which entry point is used.

def pmcxml2bioc( source: str | TextIO, sections: Iterable[str] = ('title', 'subtitle', 'abstract', 'article', 'back', 'floating'), trim_buggy_sentences: bool = True, clean_numeric_citations: bool = True, clean_xrefs_in_brackets: bool = True, clear_empty_brackets: bool = True, fix_exponentials: bool = True) -> Iterator[bioc.datastructure.BioCDocument]:
455def pmcxml2bioc(
456    source: Union[str, TextIO],
457    sections: Iterable[str] = ("title", "subtitle", "abstract", "article", "back", "floating"),
458    trim_buggy_sentences: bool = True,
459    clean_numeric_citations: bool = True,
460    clean_xrefs_in_brackets: bool = True,
461    clear_empty_brackets: bool = True,
462    fix_exponentials: bool = True,
463) -> Iterator[bioc.BioCDocument]:
464    """
465    Convert a PMC XML file into its Bioc equivalent
466
467    Args:
468        source: The text or file handle containing the PMC XML
469        sections: which of the six PMCArticle text fields ("title", "subtitle", "abstract",
470            "article", "back", "floating") to include as passages, and in what order.
471        trim_buggy_sentences: trim overly long, unbroken runs of text to a maximum length,
472            to avoid issues with buggy sentences in some PMC articles.
473        clean_numeric_citations: see parse_pmcxml.
474        clean_xrefs_in_brackets: see parse_pmcxml.
475        clear_empty_brackets: see parse_pmcxml.
476        fix_exponentials: see parse_pmcxml.
477
478    Raises:
479        RuntimeError: On any parsing errors
480
481    Returns:
482        An iterator over the newly generated Bioc documents
483    """
484    try:
485        for pmc_doc in parse_pmcxml(
486            source,
487            keep_tags=set(),
488            return_xml=False,
489            inject_citations=False,
490            trim_buggy_sentences=trim_buggy_sentences,
491            clean_numeric_citations=clean_numeric_citations,
492            clean_xrefs_in_brackets=clean_xrefs_in_brackets,
493            clear_empty_brackets=clear_empty_brackets,
494            fix_exponentials=fix_exponentials,
495        ):
496            bioc_doc = bioc.BioCDocument()
497            bioc_doc.id = pmc_doc.pmid
498            bioc_doc.infons["title"] = pmc_doc.title
499            bioc_doc.infons["pmid"] = pmc_doc.pmid
500            bioc_doc.infons["pmcid"] = pmc_doc.pmcid
501            bioc_doc.infons["doi"] = pmc_doc.doi
502            bioc_doc.infons["year"] = pmc_doc.pub_year
503            bioc_doc.infons["month"] = pmc_doc.pub_month
504            bioc_doc.infons["day"] = pmc_doc.pub_day
505            bioc_doc.infons["journal"] = pmc_doc.journal
506            bioc_doc.infons["journal_iso"] = pmc_doc.journal_iso
507
508            offset = 0
509            for group_name in sections:
510                value = getattr(pmc_doc, group_name)
511                texts = (value,) if isinstance(value, str) else value
512                for text_source in texts:
513                    if not text_source:
514                        continue
515
516                    passage = bioc.BioCPassage()
517
518                    passage.infons["section"] = group_name
519
520                    passage.text = text_source
521                    passage.offset = offset
522
523                    offset += len(text_source)
524                    bioc_doc.add_passage(passage)
525
526            yield bioc_doc
527
528    except etree.ParseError:
529        raise RuntimeError("Parsing error in PMC xml file: %s" % source)

Convert a PMC XML file into its Bioc equivalent

Arguments:
  • source: The text or file handle containing the PMC XML
  • sections: which of the six PMCArticle text fields ("title", "subtitle", "abstract", "article", "back", "floating") to include as passages, and in what order.
  • trim_buggy_sentences: trim overly long, unbroken runs of text to a maximum length, to avoid issues with buggy sentences in some PMC articles.
  • clean_numeric_citations: see parse_pmcxml.
  • clean_xrefs_in_brackets: see parse_pmcxml.
  • clear_empty_brackets: see parse_pmcxml.
  • fix_exponentials: see parse_pmcxml.
Raises:
  • RuntimeError: On any parsing errors
Returns:

An iterator over the newly generated Bioc documents

def pmcxml2txt( source: str | TextIO, sections: Iterable[str] = ('title', 'subtitle', 'abstract', 'article', 'back', 'floating'), include_metadata: bool = False, passage_separator: str = '\n\n', trim_buggy_sentences: bool = True, clean_numeric_citations: bool = True, clean_xrefs_in_brackets: bool = True, clear_empty_brackets: bool = True, fix_exponentials: bool = True) -> Iterator[str]:
532def pmcxml2txt(
533    source: Union[str, TextIO],
534    sections: Iterable[str] = ("title", "subtitle", "abstract", "article", "back", "floating"),
535    include_metadata: bool = False,
536    passage_separator: str = "\n\n",
537    trim_buggy_sentences: bool = True,
538    clean_numeric_citations: bool = True,
539    clean_xrefs_in_brackets: bool = True,
540    clear_empty_brackets: bool = True,
541    fix_exponentials: bool = True,
542) -> Iterator[str]:
543    """
544    Convert a PMC XML file into plain text, one string per article/sub-article.
545
546    Args:
547        source: The text or file handle containing the PMC XML
548        sections: which of the six PMCArticle text fields ("title", "subtitle", "abstract",
549            "article", "back", "floating") to include, and in what order.
550        include_metadata: prepend a "label: value" header block (pmid, pmcid, doi, year,
551            month, day, journal) before the text, separated by passage_separator like any
552            other passage. Fields that are empty/missing are omitted.
553        passage_separator: string used to join the header (if any), and every extracted
554            passage, into the single returned string.
555        trim_buggy_sentences: trim overly long, unbroken runs of text to a maximum length,
556            to avoid issues with buggy sentences in some PMC articles.
557        clean_numeric_citations: see parse_pmcxml.
558        clean_xrefs_in_brackets: see parse_pmcxml.
559        clear_empty_brackets: see parse_pmcxml.
560        fix_exponentials: see parse_pmcxml.
561
562    Returns:
563        An iterator over one plain text string per article/sub-article
564    """
565    for doc in parse_pmcxml(
566        source,
567        keep_tags=set(),
568        return_xml=False,
569        trim_buggy_sentences=trim_buggy_sentences,
570        clean_numeric_citations=clean_numeric_citations,
571        clean_xrefs_in_brackets=clean_xrefs_in_brackets,
572        clear_empty_brackets=clear_empty_brackets,
573        fix_exponentials=fix_exponentials,
574    ):
575        parts = []
576        if include_metadata:
577            header = _format_metadata_header(
578                {
579                    "pmid": doc.pmid,
580                    "pmcid": doc.pmcid,
581                    "doi": doc.doi,
582                    "year": doc.pub_year,
583                    "month": doc.pub_month,
584                    "day": doc.pub_day,
585                    "journal": doc.journal,
586                }
587            )
588            if header:
589                parts.append(header)
590
591        parts.extend(doc.iter_text(sections))
592
593        yield passage_separator.join(parts)

Convert a PMC XML file into plain text, one string per article/sub-article.

Arguments:
  • source: The text or file handle containing the PMC XML
  • sections: which of the six PMCArticle text fields ("title", "subtitle", "abstract", "article", "back", "floating") to include, and in what order.
  • include_metadata: prepend a "label: value" header block (pmid, pmcid, doi, year, month, day, journal) before the text, separated by passage_separator like any other passage. Fields that are empty/missing are omitted.
  • passage_separator: string used to join the header (if any), and every extracted passage, into the single returned string.
  • trim_buggy_sentences: trim overly long, unbroken runs of text to a maximum length, to avoid issues with buggy sentences in some PMC articles.
  • clean_numeric_citations: see parse_pmcxml.
  • clean_xrefs_in_brackets: see parse_pmcxml.
  • clear_empty_brackets: see parse_pmcxml.
  • fix_exponentials: see parse_pmcxml.
Returns:

An iterator over one plain text string per article/sub-article

@dataclass
class PMCArticle(bioconverters.pmc_types._PMCMeta):
 37@dataclass
 38class PMCArticle(_PMCMeta):
 39    """One PMC article or sub-article, as extracted by `parse_pmcxml`."""
 40
 41    # Redeclared here (not just inherited from _PMCMeta) purely so pdoc documents them on
 42    # this class - it doesn't inline a base class's fields into a subclass's page. Dataclass
 43    # field order/behavior is unaffected: re-annotating an inherited field updates its type
 44    # in place without moving it, since ordering is fixed by each name's first occurrence
 45    # when walking the MRO.
 46    pmid: str
 47    """PubMed ID, or an empty string if not found."""
 48
 49    pmcid: str
 50    """PubMed Central ID, or an empty string if not found."""
 51
 52    doi: str
 53    """DOI, or an empty string if not found."""
 54
 55    pub_year: Optional[str]
 56    """Publication year, or None if not found."""
 57
 58    pub_month: Optional[Union[str, int]]
 59    """Publication month, or None if not found."""
 60
 61    pub_day: Optional[str]
 62    """Publication day, or None if not found."""
 63
 64    journal: str
 65    """Journal title, or an empty string if not found."""
 66
 67    journal_iso: str
 68    """ISO abbreviation of the journal title, or an empty string if not found."""
 69
 70    title: str
 71    """Article title, or an empty string if not found."""
 72
 73    subtitle: str
 74    """Article subtitle, or an empty string if not present."""
 75
 76    abstract: Iterable[str]
 77    """Abstract passages."""
 78
 79    article: Iterable[str]
 80    """Body text passages, extracted from the article's `<body>` element."""
 81
 82    back: Iterable[str]
 83    """Back-matter text passages (e.g. appendices), extracted from the article's `<back>` element."""
 84
 85    floating: Iterable[str]
 86    """Text passages from blocks outside the main flow (e.g. figure/table captions),
 87    extracted from the article's `<floats-group>` element."""
 88
 89    def iter_text(self, sections: Iterable[str] = _ALL_SECTIONS) -> Iterator[str]:
 90        """
 91        Yield each non-empty passage of text from the given fields, in order.
 92
 93        Args:
 94            sections: which of the six text fields to pull text from, and in what order.
 95                Defaults to all six ("title", "subtitle", "abstract", "article", "back",
 96                "floating").
 97        """
 98        for section in sections:
 99            value = getattr(self, section)
100            texts = (value,) if isinstance(value, str) else value
101            for text in texts:
102                if text:
103                    yield text

One PMC article or sub-article, as extracted by parse_pmcxml.

PMCArticle( pmid: str, pmcid: str, doi: str, pub_year: str | None, pub_month: str | int | None, pub_day: str | None, journal: str, journal_iso: str, title: str, subtitle: str, abstract: Iterable[str], article: Iterable[str], back: Iterable[str], floating: Iterable[str])
pmid: str

PubMed ID, or an empty string if not found.

pmcid: str

PubMed Central ID, or an empty string if not found.

doi: str

DOI, or an empty string if not found.

pub_year: str | None

Publication year, or None if not found.

pub_month: str | int | None

Publication month, or None if not found.

pub_day: str | None

Publication day, or None if not found.

journal: str

Journal title, or an empty string if not found.

journal_iso: str

ISO abbreviation of the journal title, or an empty string if not found.

title: str

Article title, or an empty string if not found.

subtitle: str

Article subtitle, or an empty string if not present.

abstract: Iterable[str]

Abstract passages.

article: Iterable[str]

Body text passages, extracted from the article's <body> element.

back: Iterable[str]

Back-matter text passages (e.g. appendices), extracted from the article's <back> element.

floating: Iterable[str]

Text passages from blocks outside the main flow (e.g. figure/table captions), extracted from the article's <floats-group> element.

def iter_text( self, sections: Iterable[str] = ('title', 'subtitle', 'abstract', 'article', 'back', 'floating')) -> Iterator[str]:
 89    def iter_text(self, sections: Iterable[str] = _ALL_SECTIONS) -> Iterator[str]:
 90        """
 91        Yield each non-empty passage of text from the given fields, in order.
 92
 93        Args:
 94            sections: which of the six text fields to pull text from, and in what order.
 95                Defaults to all six ("title", "subtitle", "abstract", "article", "back",
 96                "floating").
 97        """
 98        for section in sections:
 99            value = getattr(self, section)
100            texts = (value,) if isinstance(value, str) else value
101            for text in texts:
102                if text:
103                    yield text

Yield each non-empty passage of text from the given fields, in order.

Arguments:
  • sections: which of the six text fields to pull text from, and in what order. Defaults to all six ("title", "subtitle", "abstract", "article", "back", "floating").
def parse_pubmedxml( source: str | TextIO, clear_empty_brackets: bool = True, fix_exponentials: bool = True) -> Iterable[PubMedArticle]:
135def parse_pubmedxml(
136    source: Union[str, TextIO],
137    clear_empty_brackets: bool = True,
138    fix_exponentials: bool = True,
139) -> Iterable[PubMedArticle]:
140    """
141    Args:
142        source: path to the MEDLINE xml file
143        clear_empty_brackets: remove any "(...)"/"[...]"/"{...}" left containing no word
144            characters.
145        fix_exponentials: recover a digit-preceded numeric `<sup>` as "^N" instead of losing
146            it to plain concatenation, e.g. `"10<sup>8</sup>"` -> "10^8". Same default as
147            pubmedxml2txt/pubmedxml2bioc.
148    """
149    for event, elem in etree.iterparse(source, events=("start", "end", "start-ns", "end-ns")):
150        if event == "end" and elem.tag == "PubmedArticle":  # MedlineCitation'):
151            # Try to extract the pmid_id
152            pmid_field = elem.find("./MedlineCitation/PMID")
153            assert pmid_field is not None
154            pmid = pmid_field.text
155
156            journal_year, journal_month, journal_day = _get_journal_date_for_medline_file(elem, pmid)
157            entry_year, entry_month, entry_day = _get_pubmed_entry_date(elem)
158
159            j_comparison = tuple(
160                9999 if d is None else d for d in [journal_year, journal_month, journal_day]
161            )
162            e_comparison = tuple(
163                9999 if d is None else d for d in [entry_year, entry_month, entry_day]
164            )
165            if (
166                j_comparison < e_comparison
167            ):  # The PubMed entry has been delayed for some reason so let's try the journal data
168                pub_year, pub_month, pub_day = journal_year, journal_month, journal_day
169            else:
170                pub_year, pub_month, pub_day = entry_year, entry_month, entry_day
171
172            # Extract the authors
173            author_elems = elem.findall("./MedlineCitation/Article/AuthorList/Author")
174            authors = []
175            for author_elem in author_elems:
176                forename = author_elem.find("./ForeName")
177                lastname = author_elem.find("./LastName")
178                collectivename = author_elem.find("./CollectiveName")
179
180                name = None
181                if (
182                    forename is not None
183                    and lastname is not None
184                    and forename.text is not None
185                    and lastname.text is not None
186                ):
187                    name = "%s %s" % (forename.text, lastname.text)
188                elif lastname is not None and lastname.text is not None:
189                    name = lastname.text
190                elif forename is not None and forename.text is not None:
191                    name = forename.text
192                elif collectivename is not None and collectivename.text is not None:
193                    name = collectivename.text
194                else:
195                    raise RuntimeError("Unable to find authors in Pubmed citation (PMID=%s)" % pmid)
196                authors.append(name)
197
198            chemicals = []
199            chemical_elems = elem.findall("./MedlineCitation/ChemicalList/Chemical")
200            for chemical_elem in chemical_elems:
201                substance_elem = chemical_elem.find("./NameOfSubstance")
202                chemicals.append(
203                    Chemical(
204                        ui=substance_elem.attrib["UI"],
205                        name=substance_elem.text,
206                        registry_number=chemical_elem.find("./RegistryNumber").text,
207                    )
208                )
209
210            mesh_headings = []
211            mesh_elems = elem.findall("./MedlineCitation/MeshHeadingList/MeshHeading")
212            for mesh_elem in mesh_elems:
213                descriptor_elem = mesh_elem.find("./DescriptorName")
214                qualifiers = [
215                    MeshQualifier(
216                        ui=qualifier_elem.attrib["UI"],
217                        name=qualifier_elem.text,
218                        major_topic=qualifier_elem.attrib["MajorTopicYN"] == "Y",
219                    )
220                    for qualifier_elem in mesh_elem.findall("./QualifierName")
221                ]
222                mesh_headings.append(
223                    MeshHeading(
224                        ui=descriptor_elem.attrib["UI"],
225                        name=descriptor_elem.text,
226                        major_topic=descriptor_elem.attrib["MajorTopicYN"] == "Y",
227                        qualifiers=qualifiers,
228                    )
229                )
230
231            supplementary_concepts = [
232                SupplementaryMeshConcept(
233                    ui=concept_elem.attrib["UI"],
234                    type=concept_elem.attrib["Type"],
235                    name=concept_elem.text,
236                )
237                for concept_elem in elem.findall("./MedlineCitation/SupplMeshList/SupplMeshName")
238            ]
239
240            doi_elems = elem.findall("./PubmedData/ArticleIdList/ArticleId[@IdType='doi']")
241            dois = [
242                doi_elem.text
243                for doi_elem in doi_elems
244                if doi_elem.text and _doi_regex.match(doi_elem.text)
245            ]
246
247            doi = None
248            if dois:
249                doi = dois[0]  # We'll just use DOI the first one provided
250
251            pmc_elems = elem.findall("./PubmedData/ArticleIdList/ArticleId[@IdType='pmc']")
252            assert len(pmc_elems) <= 1, "Found more than one PMCID with PMID: %s" % pmid
253            pmcid = None
254            if len(pmc_elems) == 1:
255                pmcid = pmc_elems[0].text
256
257            pub_type_elems = elem.findall(
258                "./MedlineCitation/Article/PublicationTypeList/PublicationType"
259            )
260            publication_types = [
261                PublicationType(ui=e.attrib["UI"], name=e.text)
262                for e in pub_type_elems
263                if e.text not in _pub_type_skips
264            ]
265
266            # Extract the title of paper - the DTD requires exactly one ArticleTitle per Article
267            title = elem.findall("./MedlineCitation/Article/ArticleTitle")
268            assert len(title) == 1, "Expected exactly one ArticleTitle for PMID=%s" % pmid
269            title_passages = _extract_passages(
270                title,
271                PUBMED_IGNORE_TAGS,
272                PUBMED_SPLIT_TAGS,
273                PUBMED_KEEP_TAGS,
274                return_xml=False,
275                trim_buggy_sentences=True,
276                fix_exponentials=fix_exponentials,
277            )
278            title_text = _remove_brackets_from_titles(title_passages[0])
279            title_text = html.unescape(title_text)
280            if clear_empty_brackets:
281                title_text = _remove_brackets_without_words(title_text)
282
283            # Extract the abstract from the paper
284            abstract = elem.findall("./MedlineCitation/Article/Abstract/AbstractText")
285            abstract_passages = _extract_passages(
286                abstract,
287                PUBMED_IGNORE_TAGS,
288                PUBMED_SPLIT_TAGS,
289                PUBMED_KEEP_TAGS,
290                return_xml=False,
291                trim_buggy_sentences=True,
292                fix_exponentials=fix_exponentials,
293            )
294            abstract_text = [html.unescape(t) for t in abstract_passages]
295            if clear_empty_brackets:
296                abstract_text = [_remove_brackets_without_words(t) for t in abstract_text]
297
298            journal_title_fields = elem.findall("./MedlineCitation/Article/Journal/Title")
299            journal_title_iso_fields = elem.findall(
300                "./MedlineCitation/Article/Journal/ISOAbbreviation"
301            )
302
303            journal_title, journal_iso_title = "", ""
304            assert len(journal_title_fields) <= 1, "Error with pmid=%s" % pmid
305            assert len(journal_title_iso_fields) <= 1, "Error with pmid=%s" % pmid
306            if journal_title_fields:
307                journal_title = journal_title_fields[0].text
308            if journal_title_iso_fields:
309                journal_iso_title = journal_title_iso_fields[0].text
310
311            yield PubMedArticle(
312                pmid=pmid,
313                pmcid=pmcid,
314                doi=doi,
315                pub_year=pub_year,
316                pub_month=pub_month,
317                pub_day=pub_day,
318                title=title_text,
319                abstract=abstract_text,
320                journal=journal_title,
321                journal_iso=journal_iso_title,
322                authors=authors,
323                chemicals=chemicals,
324                mesh_headings=mesh_headings,
325                supplementary_mesh=supplementary_concepts,
326                publication_types=publication_types,
327            )
328
329            # Important: clear the current element from memory to keep memory usage low
330            elem.clear()
Arguments:
  • source: path to the MEDLINE xml file
  • clear_empty_brackets: remove any "(...)"/"[...]"/"{...}" left containing no word characters.
  • fix_exponentials: recover a digit-preceded numeric <sup> as "^N" instead of losing it to plain concatenation, e.g. "10<sup>8</sup>" -> "10^8". Same default as pubmedxml2txt/pubmedxml2bioc.
def pubmedxml2bioc( source: str | TextIO, sections: Iterable[str] = ('title', 'abstract'), clear_empty_brackets: bool = True, fix_exponentials: bool = True) -> Iterable[bioc.datastructure.BioCDocument]:
353def pubmedxml2bioc(
354    source: Union[str, TextIO],
355    sections: Iterable[str] = ("title", "abstract"),
356    clear_empty_brackets: bool = True,
357    fix_exponentials: bool = True,
358) -> Iterable[bioc.BioCDocument]:
359    """
360    Args:
361        source: path to the MEDLINE xml file
362        sections: which of "title"/"abstract" to include, and in what order.
363        clear_empty_brackets: see parse_pubmedxml.
364        fix_exponentials: see parse_pubmedxml.
365    """
366    for pm_doc in parse_pubmedxml(
367        source, clear_empty_brackets=clear_empty_brackets, fix_exponentials=fix_exponentials
368    ):
369        bioc_doc = bioc.BioCDocument()
370        bioc_doc.id = pm_doc.pmid
371        bioc_doc.infons["title"] = pm_doc.title
372        bioc_doc.infons["pmid"] = pm_doc.pmid
373        bioc_doc.infons["pmcid"] = pm_doc.pmcid
374        bioc_doc.infons["doi"] = pm_doc.doi
375        bioc_doc.infons["year"] = pm_doc.pub_year
376        bioc_doc.infons["month"] = pm_doc.pub_month
377        bioc_doc.infons["day"] = pm_doc.pub_day
378        bioc_doc.infons["journal"] = pm_doc.journal
379        bioc_doc.infons["journal_iso"] = pm_doc.journal_iso
380        bioc_doc.infons["authors"] = ", ".join(pm_doc.authors)
381        bioc_doc.infons["chemicals"] = "\t".join(_format_chemical_for_infons(c) for c in pm_doc.chemicals)
382        bioc_doc.infons["mesh_headings"] = "\t".join(
383            _format_mesh_heading_for_infons(h) for h in pm_doc.mesh_headings
384        )
385        bioc_doc.infons["supplementary_mesh"] = "\t".join(
386            _format_supplementary_mesh_for_infons(s) for s in pm_doc.supplementary_mesh
387        )
388        bioc_doc.infons["publication_types"] = "\t".join(
389            _format_publication_type_for_infons(p) for p in pm_doc.publication_types
390        )
391
392        offset = 0
393        for section in sections:
394            value = getattr(pm_doc, section)
395            texts = (value,) if isinstance(value, str) else value
396            for text_source in texts:
397                if not text_source:
398                    continue
399                passage = bioc.BioCPassage()
400                passage.infons["section"] = section
401                passage.text = text_source
402                passage.offset = offset
403                offset += len(text_source)
404                bioc_doc.add_passage(passage)
405
406        yield bioc_doc
Arguments:
  • source: path to the MEDLINE xml file
  • sections: which of "title"/"abstract" to include, and in what order.
  • clear_empty_brackets: see parse_pubmedxml.
  • fix_exponentials: see parse_pubmedxml.
def pubmedxml2txt( source: str | TextIO, sections: Iterable[str] = ('title', 'abstract'), include_metadata: bool = False, passage_separator: str = '\n\n', clear_empty_brackets: bool = True, fix_exponentials: bool = True) -> Iterator[str]:
409def pubmedxml2txt(
410    source: Union[str, TextIO],
411    sections: Iterable[str] = ("title", "abstract"),
412    include_metadata: bool = False,
413    passage_separator: str = "\n\n",
414    clear_empty_brackets: bool = True,
415    fix_exponentials: bool = True,
416) -> Iterator[str]:
417    """
418    Convert a MEDLINE XML file into plain text, one string per article.
419
420    Args:
421        source: path to the MEDLINE xml file
422        sections: which of "title"/"abstract" to include, and in what order.
423        include_metadata: prepend a "label: value" header block (pmid, pmcid, doi, year,
424            month, day, journal, authors) before the text, separated by passage_separator
425            like any other passage. Fields that are empty/missing are omitted.
426        passage_separator: string used to join the header (if any), and every extracted
427            passage, into the single returned string.
428        clear_empty_brackets: see parse_pubmedxml.
429        fix_exponentials: see parse_pubmedxml.
430
431    Returns:
432        An iterator over one plain text string per article
433    """
434    for pm_doc in parse_pubmedxml(
435        source, clear_empty_brackets=clear_empty_brackets, fix_exponentials=fix_exponentials
436    ):
437        parts = []
438        if include_metadata:
439            header = _format_metadata_header(
440                {
441                    "pmid": pm_doc.pmid,
442                    "pmcid": pm_doc.pmcid,
443                    "doi": pm_doc.doi,
444                    "year": pm_doc.pub_year,
445                    "month": pm_doc.pub_month,
446                    "day": pm_doc.pub_day,
447                    "journal": pm_doc.journal,
448                    "authors": "; ".join(pm_doc.authors) if pm_doc.authors else None,
449                }
450            )
451            if header:
452                parts.append(header)
453
454        parts.extend(pm_doc.iter_text(sections))
455
456        yield passage_separator.join(parts)

Convert a MEDLINE XML file into plain text, one string per article.

Arguments:
  • source: path to the MEDLINE xml file
  • sections: which of "title"/"abstract" to include, and in what order.
  • include_metadata: prepend a "label: value" header block (pmid, pmcid, doi, year, month, day, journal, authors) before the text, separated by passage_separator like any other passage. Fields that are empty/missing are omitted.
  • passage_separator: string used to join the header (if any), and every extracted passage, into the single returned string.
  • clear_empty_brackets: see parse_pubmedxml.
  • fix_exponentials: see parse_pubmedxml.
Returns:

An iterator over one plain text string per article

@dataclass
class PubMedArticle:
 76@dataclass
 77class PubMedArticle:
 78    """One MEDLINE/PubMed article, as extracted by `parse_pubmedxml`."""
 79
 80    pmid: str
 81    """PubMed ID."""
 82
 83    pmcid: Optional[str]
 84    """PubMed Central ID, or None if not linked."""
 85
 86    doi: Optional[str]
 87    """DOI, or None if not found."""
 88
 89    pub_year: Optional[int]
 90    """Publication year, or None if not found."""
 91
 92    pub_month: Optional[int]
 93    """Publication month, or None if not found."""
 94
 95    pub_day: Optional[int]
 96    """Publication day, or None if not found."""
 97
 98    title: str
 99    """Article title."""
100
101    abstract: Iterable[str]
102    """Abstract passages, one per `<AbstractText>` element."""
103
104    journal: str
105    """Journal title, or an empty string if not found."""
106
107    journal_iso: str
108    """ISO abbreviation of the journal title, or an empty string if not found."""
109
110    authors: Iterable[str]
111    """Author names, in document order."""
112
113    chemicals: Iterable[Chemical]
114    """Chemical substances, in document order."""
115
116    mesh_headings: Iterable[MeshHeading]
117    """MeSH headings, each with its own qualifiers nested inside, in document order."""
118
119    supplementary_mesh: Iterable[SupplementaryMeshConcept]
120    """Supplementary MeSH concepts, in document order."""
121
122    publication_types: Iterable[PublicationType]
123    """Publication types, in document order (excludes generic NLM support-type labels
124    like "Research Support, N.I.H., Extramural")."""
125
126    def iter_text(self, sections: Iterable[str] = ("title", "abstract")) -> Iterator[str]:
127        """
128        Yield each non-empty passage of text from the given fields, in order.
129
130        Args:
131            sections: which fields to pull text from, and in what order. Defaults to
132                ("title", "abstract").
133        """
134        for section in sections:
135            value = getattr(self, section)
136            texts = (value,) if isinstance(value, str) else value
137            for text in texts:
138                if text:
139                    yield text

One MEDLINE/PubMed article, as extracted by parse_pubmedxml.

PubMedArticle( pmid: str, pmcid: str | None, doi: str | None, pub_year: int | None, pub_month: int | None, pub_day: int | None, title: str, abstract: Iterable[str], journal: str, journal_iso: str, authors: Iterable[str], chemicals: Iterable[Chemical], mesh_headings: Iterable[MeshHeading], supplementary_mesh: Iterable[SupplementaryMeshConcept], publication_types: Iterable[PublicationType])
pmid: str

PubMed ID.

pmcid: str | None

PubMed Central ID, or None if not linked.

doi: str | None

DOI, or None if not found.

pub_year: int | None

Publication year, or None if not found.

pub_month: int | None

Publication month, or None if not found.

pub_day: int | None

Publication day, or None if not found.

title: str

Article title.

abstract: Iterable[str]

Abstract passages, one per <AbstractText> element.

journal: str

Journal title, or an empty string if not found.

journal_iso: str

ISO abbreviation of the journal title, or an empty string if not found.

authors: Iterable[str]

Author names, in document order.

chemicals: Iterable[Chemical]

Chemical substances, in document order.

mesh_headings: Iterable[MeshHeading]

MeSH headings, each with its own qualifiers nested inside, in document order.

supplementary_mesh: Iterable[SupplementaryMeshConcept]

Supplementary MeSH concepts, in document order.

publication_types: Iterable[PublicationType]

Publication types, in document order (excludes generic NLM support-type labels like "Research Support, N.I.H., Extramural").

def iter_text(self, sections: Iterable[str] = ('title', 'abstract')) -> Iterator[str]:
126    def iter_text(self, sections: Iterable[str] = ("title", "abstract")) -> Iterator[str]:
127        """
128        Yield each non-empty passage of text from the given fields, in order.
129
130        Args:
131            sections: which fields to pull text from, and in what order. Defaults to
132                ("title", "abstract").
133        """
134        for section in sections:
135            value = getattr(self, section)
136            texts = (value,) if isinstance(value, str) else value
137            for text in texts:
138                if text:
139                    yield text

Yield each non-empty passage of text from the given fields, in order.

Arguments:
  • sections: which fields to pull text from, and in what order. Defaults to ("title", "abstract").
@dataclass
class Chemical:
 6@dataclass
 7class Chemical:
 8    """One `<Chemical>` entry from the article's ChemicalList."""
 9
10    ui: str
11    """MeSH substance ID (e.g. "D000068877")."""
12
13    name: str
14    """Substance name."""
15
16    registry_number: str
17    """CAS registry number, or "0" if none is assigned."""

One <Chemical> entry from the article's ChemicalList.

Chemical(ui: str, name: str, registry_number: str)
ui: str

MeSH substance ID (e.g. "D000068877").

name: str

Substance name.

registry_number: str

CAS registry number, or "0" if none is assigned.

@dataclass
class MeshHeading:
34@dataclass
35class MeshHeading:
36    """One `<MeshHeading>` entry: a descriptor plus any qualifiers refining it."""
37
38    ui: str
39    """MeSH descriptor ID (e.g. "D009369")."""
40
41    name: str
42    """Descriptor name (e.g. "Neoplasms")."""
43
44    major_topic: bool
45    """Whether the descriptor itself is a major topic (MajorTopicYN="Y")."""
46
47    qualifiers: Iterable[MeshQualifier]
48    """Qualifiers refining this descriptor, in document order."""

One <MeshHeading> entry: a descriptor plus any qualifiers refining it.

MeshHeading( ui: str, name: str, major_topic: bool, qualifiers: Iterable[MeshQualifier])
ui: str

MeSH descriptor ID (e.g. "D009369").

name: str

Descriptor name (e.g. "Neoplasms").

major_topic: bool

Whether the descriptor itself is a major topic (MajorTopicYN="Y").

qualifiers: Iterable[MeshQualifier]

Qualifiers refining this descriptor, in document order.

@dataclass
class MeshQualifier:
20@dataclass
21class MeshQualifier:
22    """One `<QualifierName>` attached to a MeSH heading's descriptor."""
23
24    ui: str
25    """MeSH qualifier ID (e.g. "Q000378")."""
26
27    name: str
28    """Qualifier name (e.g. "genetics")."""
29
30    major_topic: bool
31    """Whether this qualifier is a major topic of the article (MajorTopicYN="Y")."""

One <QualifierName> attached to a MeSH heading's descriptor.

MeshQualifier(ui: str, name: str, major_topic: bool)
ui: str

MeSH qualifier ID (e.g. "Q000378").

name: str

Qualifier name (e.g. "genetics").

major_topic: bool

Whether this qualifier is a major topic of the article (MajorTopicYN="Y").

@dataclass
class SupplementaryMeshConcept:
51@dataclass
52class SupplementaryMeshConcept:
53    """One `<SupplMeshName>` entry from the article's SupplMeshList."""
54
55    ui: str
56    """Supplementary concept ID (e.g. "C000657245")."""
57
58    type: str
59    """One of "Disease", "Protocol", "Organism", "Anatomy", "Population" (DTD-enumerated)."""
60
61    name: str
62    """Concept name."""

One <SupplMeshName> entry from the article's SupplMeshList.

SupplementaryMeshConcept(ui: str, type: str, name: str)
ui: str

Supplementary concept ID (e.g. "C000657245").

type: str

One of "Disease", "Protocol", "Organism", "Anatomy", "Population" (DTD-enumerated).

name: str

Concept name.

@dataclass
class PublicationType:
65@dataclass
66class PublicationType:
67    """One `<PublicationType>` entry from the article's PublicationTypeList."""
68
69    ui: str
70    """MeSH publication-type ID (e.g. "D016428")."""
71
72    name: str
73    """Publication type name (e.g. "Journal Article")."""

One <PublicationType> entry from the article's PublicationTypeList.

PublicationType(ui: str, name: str)
ui: str

MeSH publication-type ID (e.g. "D016428").

name: str

Publication type name (e.g. "Journal Article").