linkml-reference-validator 0.2.1rc1__tar.gz → 0.2.1rc2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/PKG-INFO +30 -4
  2. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/README.md +29 -3
  3. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/fetch-full-text-and-pdfs.md +49 -3
  4. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/fulltext/__init__.py +2 -0
  5. linkml_reference_validator-0.2.1rc2/src/linkml_reference_validator/etl/fulltext/epmc_preprint.py +142 -0
  6. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/identifiers.py +5 -1
  7. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/reference_fetcher.py +8 -0
  8. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/__init__.py +2 -0
  9. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/doi.py +34 -0
  10. linkml_reference_validator-0.2.1rc2/src/linkml_reference_validator/etl/sources/ppr.py +159 -0
  11. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/models.py +28 -3
  12. linkml_reference_validator-0.2.1rc2/tests/test_doi_preprint.py +103 -0
  13. linkml_reference_validator-0.2.1rc2/tests/test_epmc_preprint_provider.py +196 -0
  14. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_identifiers.py +25 -0
  15. linkml_reference_validator-0.2.1rc2/tests/test_ppr_source.py +125 -0
  16. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_reference_fetcher.py +37 -0
  17. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_validation_config.py +1 -1
  18. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.claude/.mcp.json +0 -0
  19. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.claude/settings.json +0 -0
  20. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.copier-answers.yml +0 -0
  21. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.devcontainer/devcontainer.json +0 -0
  22. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.devcontainer/post_create.sh +0 -0
  23. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.editorconfig +0 -0
  24. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/actions/claude-code-action/action.yml +0 -0
  25. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/actions/claude-issue-summarize-action/action.yml +0 -0
  26. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/actions/claude-issue-triage-action/action.yml +0 -0
  27. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/ai-controllers.json +0 -0
  28. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/copilot-instructions.md +0 -0
  29. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/copilot-setup-steps.yml +0 -0
  30. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/dependabot.yml +0 -0
  31. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/claude-code-review.yml +0 -0
  32. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/claude-issue-summarize.yml +0 -0
  33. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/claude-issue-triage.yml +0 -0
  34. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/claude-ontology-review.yml +0 -0
  35. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/claude.yml +0 -0
  36. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/deploy-docs.yaml +0 -0
  37. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/dragon-ai.yml +0 -0
  38. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/main.yaml +0 -0
  39. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.github/workflows/pypi-publish.yaml +0 -0
  40. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.gitignore +0 -0
  41. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.goosehints +0 -0
  42. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.pre-commit-config.yaml +0 -0
  43. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/.yamllint.yaml +0 -0
  44. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/AGENTS.md +0 -0
  45. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/BUG_GEO_ACCESSION_TO_UID.md +0 -0
  46. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/BUG_ZENODO_DOI_AND_URL_HANDLING.md +0 -0
  47. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/CLAUDE.md +0 -0
  48. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/CLI-ISSUES.md +0 -0
  49. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/CODE_OF_CONDUCT.md +0 -0
  50. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/CONTRIBUTING.md +0 -0
  51. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/FEATURE_REQUEST_REPAIR.md +0 -0
  52. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/LICENSE +0 -0
  53. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/REVIEW2.md +0 -0
  54. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/REVIEW3.md +0 -0
  55. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/ai.just +0 -0
  56. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/copilot-instructions.md +0 -0
  57. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/about.md +0 -0
  58. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/concepts/content-types.md +0 -0
  59. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/concepts/editorial-conventions.md +0 -0
  60. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/concepts/how-it-works.md +0 -0
  61. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/elements/.gitkeep +0 -0
  62. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/add-reference-source.md +0 -0
  63. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/repair-validation-errors.md +0 -0
  64. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/skip-unsupported-references.md +0 -0
  65. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/use-local-files-and-urls.md +0 -0
  66. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/validate-clinical-trials.md +0 -0
  67. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/validate-dois.md +0 -0
  68. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/validate-entrez.md +0 -0
  69. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/validate-obo-files.md +0 -0
  70. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/validate-titles.md +0 -0
  71. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/how-to/validate-urls.md +0 -0
  72. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/index.md +0 -0
  73. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/notebooks/01_getting_started.ipynb +0 -0
  74. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/notebooks/02_advanced_usage.ipynb +0 -0
  75. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/notebooks/03_python_api.ipynb +0 -0
  76. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/notebooks/04_obo_validation.ipynb +0 -0
  77. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/notebooks/05_geo_validation.ipynb +0 -0
  78. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/quickstart.md +0 -0
  79. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/reference/cli.md +0 -0
  80. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/setup-guide.md +0 -0
  81. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/superpowers/plans/2026-06-12-modular-pdf-fulltext-fetching.md +0 -0
  82. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/superpowers/specs/2026-06-12-modular-pdf-fulltext-fetching-design.md +0 -0
  83. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/templates-linkml/README.md +0 -0
  84. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/todo.md +0 -0
  85. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/troubleshooting.md +0 -0
  86. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/docs/tutorials/complete-workflow.md +0 -0
  87. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/examples/README.md +0 -0
  88. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/justfile +0 -0
  89. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/mkdocs.yml +0 -0
  90. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/mypy.ini +0 -0
  91. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/notebooks/01_getting_started.ipynb +0 -0
  92. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/notebooks/02_advanced_usage.ipynb +0 -0
  93. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/notebooks/03_python_api.ipynb +0 -0
  94. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/notebooks/README.md +0 -0
  95. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/project/README.md +0 -0
  96. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/project.justfile +0 -0
  97. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/pyproject.toml +0 -0
  98. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/pytest.ini +0 -0
  99. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/__init__.py +0 -0
  100. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/_version.py +0 -0
  101. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/cli/__init__.py +0 -0
  102. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/cli/cache.py +0 -0
  103. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/cli/lookup.py +0 -0
  104. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/cli/repair.py +0 -0
  105. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/cli/shared.py +0 -0
  106. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/cli/validate.py +0 -0
  107. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/__init__.py +0 -0
  108. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/acquire.py +0 -0
  109. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/extract/__init__.py +0 -0
  110. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/extract/base.py +0 -0
  111. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/extract/html.py +0 -0
  112. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/extract/pdf.py +0 -0
  113. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/extract/xml.py +0 -0
  114. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/fulltext/base.py +0 -0
  115. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/fulltext/json_api.py +0 -0
  116. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/fulltext/loader.py +0 -0
  117. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/fulltext/openalex.py +0 -0
  118. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/fulltext/pmc.py +0 -0
  119. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/fulltext/unpaywall.py +0 -0
  120. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/base.py +0 -0
  121. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/clinicaltrials.py +0 -0
  122. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/entrez.py +0 -0
  123. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/file.py +0 -0
  124. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/json_api.py +0 -0
  125. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/loader.py +0 -0
  126. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/pmid.py +0 -0
  127. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/url.py +0 -0
  128. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/sources/utils.py +0 -0
  129. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/etl/text_extractor.py +0 -0
  130. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/field_detection.py +0 -0
  131. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/plugins/REVIEW.md +0 -0
  132. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/plugins/__init__.py +0 -0
  133. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/plugins/reference_validation_plugin.py +0 -0
  134. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/validation/__init__.py +0 -0
  135. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/validation/fuzzy_text_utils.py +0 -0
  136. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/validation/repairer.py +0 -0
  137. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/src/linkml_reference_validator/validation/supporting_text_validator.py +0 -0
  138. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/__init__.py +0 -0
  139. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/conftest.py +0 -0
  140. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/data/test_data_deep_nesting_invalid.yaml +0 -0
  141. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/data/test_data_deep_nesting_valid.yaml +0 -0
  142. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/data/test_data_invalid.yaml +0 -0
  143. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/data/test_data_valid.yaml +0 -0
  144. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/data/test_schema.yaml +0 -0
  145. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/data/test_schema_deep_nesting.yaml +0 -0
  146. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/CLINICALTRIALS_NCT00000001.md +0 -0
  147. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/GEO_GSE12345.md +0 -0
  148. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMC_TEST001.md +0 -0
  149. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMC_TEST001.txt +0 -0
  150. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMC_TEST002.md +0 -0
  151. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMC_TEST002.txt +0 -0
  152. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMID_TEST001.md +0 -0
  153. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMID_TEST001.txt +0 -0
  154. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMID_TEST002.md +0 -0
  155. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMID_TEST002.txt +0 -0
  156. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMID_TEST003.md +0 -0
  157. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/PMID_TEST003.txt +0 -0
  158. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/fixtures/sample.obo +0 -0
  159. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_acquire.py +0 -0
  160. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_cli.py +0 -0
  161. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_cli_lookup.py +0 -0
  162. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_cli_repair.py +0 -0
  163. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_clinicaltrials_integration.py +0 -0
  164. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_deep_nesting.py +0 -0
  165. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_e2e_integration.py +0 -0
  166. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_extractors.py +0 -0
  167. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_field_detection.py +0 -0
  168. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_fulltext_loader.py +0 -0
  169. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_fulltext_providers.py +0 -0
  170. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_fuzzy_suggestions.py +0 -0
  171. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_geo_integration.py +0 -0
  172. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_json_api_source.py +0 -0
  173. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_models.py +0 -0
  174. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_optional_linkml_dependency.py +0 -0
  175. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_plugin_integration.py +0 -0
  176. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_pmc_fulltext.py +0 -0
  177. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_real_validation.py +0 -0
  178. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_repair.py +0 -0
  179. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_source_utils.py +0 -0
  180. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_sources.py +0 -0
  181. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_supporting_text_validator.py +0 -0
  182. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_text_extractor.py +0 -0
  183. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_title_validation.py +0 -0
  184. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/tests/test_unknown_slot_traversal.py +0 -0
  185. {linkml_reference_validator-0.2.1rc1 → linkml_reference_validator-0.2.1rc2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linkml_reference_validator
3
- Version: 0.2.1rc1
3
+ Version: 0.2.1rc2
4
4
  Summary: Validation of supporting text from references and publications
5
5
  Author-email: Chris Mungall <cjmungall@lbl.gov>
6
6
  License-Expression: Apache-2.0
@@ -395,10 +395,36 @@ reference:
395
395
  - `PMID:12345678`
396
396
  - `12345678` (assumes PMID)
397
397
 
398
- ### Coming Soon
398
+ #### Preprints
399
399
 
400
- - **DOI** - `DOI:10.1038/nature12345`
401
- - **URLs** - Web pages and online documents
400
+ Preprints are supported as first-class references:
401
+
402
+ ```yaml
403
+ reference:
404
+ id: DOI:10.1101/2024.01.01.573333 # by DOI
405
+ supporting_text: "the kinase phosphorylates the substrate in vitro"
406
+ ```
407
+
408
+ ```yaml
409
+ reference:
410
+ id: PPR:PPR123456 # by Europe PMC preprint id
411
+ supporting_text: "the kinase phosphorylates the substrate in vitro"
412
+ ```
413
+
414
+ **Fetches:**
415
+ - Abstract and metadata (title, authors, server, year, DOI)
416
+ - Full-text body via the Europe PMC `fulltextRepo` PDF route (`epmc_preprint`
417
+ provider), for the Europe-PMC-ingested subset
418
+ - `is_preprint` / `peer_review_status` flags, so downstream KBs can apply
419
+ "not sole support" policies
420
+
421
+ **ID Formats:**
422
+ - `DOI:10.1101/...` (Crossref `posted-content` is detected as a preprint)
423
+ - `PPR:PPR123456` (Europe PMC `SRC:PPR` id)
424
+ - NIH Preprint Pilot preprints carry real PMIDs and resolve via the `PMID:` path
425
+
426
+ See the [full-text guide](docs/how-to/fetch-full-text-and-pdfs.md#preprints) for
427
+ details and coverage caveats.
402
428
 
403
429
  ---
404
430
 
@@ -374,10 +374,36 @@ reference:
374
374
  - `PMID:12345678`
375
375
  - `12345678` (assumes PMID)
376
376
 
377
- ### Coming Soon
377
+ #### Preprints
378
378
 
379
- - **DOI** - `DOI:10.1038/nature12345`
380
- - **URLs** - Web pages and online documents
379
+ Preprints are supported as first-class references:
380
+
381
+ ```yaml
382
+ reference:
383
+ id: DOI:10.1101/2024.01.01.573333 # by DOI
384
+ supporting_text: "the kinase phosphorylates the substrate in vitro"
385
+ ```
386
+
387
+ ```yaml
388
+ reference:
389
+ id: PPR:PPR123456 # by Europe PMC preprint id
390
+ supporting_text: "the kinase phosphorylates the substrate in vitro"
391
+ ```
392
+
393
+ **Fetches:**
394
+ - Abstract and metadata (title, authors, server, year, DOI)
395
+ - Full-text body via the Europe PMC `fulltextRepo` PDF route (`epmc_preprint`
396
+ provider), for the Europe-PMC-ingested subset
397
+ - `is_preprint` / `peer_review_status` flags, so downstream KBs can apply
398
+ "not sole support" policies
399
+
400
+ **ID Formats:**
401
+ - `DOI:10.1101/...` (Crossref `posted-content` is detected as a preprint)
402
+ - `PPR:PPR123456` (Europe PMC `SRC:PPR` id)
403
+ - NIH Preprint Pilot preprints carry real PMIDs and resolve via the `PMID:` path
404
+
405
+ See the [full-text guide](docs/how-to/fetch-full-text-and-pdfs.md#preprints) for
406
+ details and coverage caveats.
381
407
 
382
408
  ---
383
409
 
@@ -21,11 +21,13 @@ The validator tries providers in order until one returns usable full text.
21
21
  The default order is:
22
22
 
23
23
  ```
24
- pmc → unpaywall → openalex
24
+ pmc → epmc_preprint → unpaywall → openalex
25
25
  ```
26
26
 
27
27
  - **`pmc`** — PubMed Central open-access subset (XML/HTML full text for many
28
28
  biomedical articles).
29
+ - **`epmc_preprint`** — Europe PMC preprint body text via the `fulltextRepo`
30
+ PDF route (see [Preprints](#preprints) below).
29
31
  - **`unpaywall`** — Unpaywall open-access lookup by DOI (often a publisher or
30
32
  repository PDF).
31
33
  - **`openalex`** — OpenAlex open-access location (PDF/HTML).
@@ -43,7 +45,7 @@ your config YAML or on the `ReferenceValidationConfig` object):
43
45
  | Key | Default | Description |
44
46
  |-----|---------|-------------|
45
47
  | `fetch_full_text` | `true` | Attempt to obtain full text via the provider chain when a metadata source does not already return full text. |
46
- | `full_text_providers` | `[pmc, unpaywall, openalex]` | Ordered list of provider names to try until one yields usable full text. |
48
+ | `full_text_providers` | `[pmc, epmc_preprint, unpaywall, openalex]` | Ordered list of provider names to try until one yields usable full text. |
47
49
  | `pdf_backend` | `pypdf` | Name of the PDF text-extraction backend. |
48
50
  | `download_pdfs` | `true` | If true, persist downloaded PDFs to the files cache directory. |
49
51
  | `full_text_providers_file` | `null` | Optional path to a YAML file defining custom full-text providers. |
@@ -62,12 +64,56 @@ email: you@example.org
62
64
  fetch_full_text: true
63
65
  full_text_providers:
64
66
  - pmc
67
+ - epmc_preprint
65
68
  - unpaywall
66
69
  - openalex
67
70
  pdf_backend: pypdf
68
71
  download_pdfs: true
69
72
  ```
70
73
 
74
+ ## Preprints
75
+
76
+ Preprints are first-class references. They are increasingly cited for early
77
+ mechanistic findings, but they sit in two distinct universes that need
78
+ different handling:
79
+
80
+ - **NIH Preprint Pilot** — a subset of preprints carry real PMIDs, so they
81
+ resolve through the normal `PMID:` path with no special handling.
82
+ - **Europe PMC `SRC:PPR`** — the much larger preprint firehose is DOI-only
83
+ (no PMIDs). These resolve by DOI through Crossref, or directly by their
84
+ Europe PMC preprint id using the `PPR:` prefix (e.g. `PPR:PPR123456`).
85
+
86
+ ### Full-text retrieval
87
+
88
+ Preprint bodies are **not** served by the PMC `fullTextXML` endpoint — even the
89
+ ~7% with a PMCID expose only abstract/title/supplementary in PMC, and direct
90
+ `biorxiv.org/...full.pdf` links sit behind a bot challenge. The route that
91
+ works for automated clients is the Europe PMC `fulltextRepo` PDF, used by the
92
+ `epmc_preprint` provider: it resolves a preprint by DOI (or `SRC:PPR` id),
93
+ finds the open `fulltextRepo` PDF URL, and downloads it. The PDF's `%PDF-`
94
+ magic bytes are validated before extraction, so a stale-filename error blob is
95
+ rejected rather than fed to the PDF parser, and the chain falls through
96
+ gracefully.
97
+
98
+ Coverage note: the `epmc_preprint` route covers the Europe-PMC-ingested
99
+ preprint subset. The newest openRxiv DOIs (prefix `10.64898`) are not yet in
100
+ Europe PMC, so very recent preprints may resolve to metadata only until they
101
+ are ingested.
102
+
103
+ ### Preprint / peer-review status
104
+
105
+ When a reference is detected as a preprint — via the Crossref `posted-content`
106
+ type or a Europe PMC `SRC:PPR` record — the cached reference records it:
107
+
108
+ | Field | Description |
109
+ |-------|-------------|
110
+ | `is_preprint` | `true` for a preprint, `false` when the source authoritatively classifies it as not a preprint (e.g. a Crossref `journal-article`), and unset when the type is unknown. |
111
+ | `peer_review_status` | `preprint` for preprints; left unset otherwise. |
112
+
113
+ Downstream knowledge bases can use these fields to apply policies such as "a
114
+ preprint may not be the sole support for a claim". A non-preprint is not
115
+ positively asserted as `peer_reviewed`; `peer_review_status` stays unset.
116
+
71
117
  ## CLI flag
72
118
 
73
119
  Every `validate` subcommand accepts a `--full-text/--no-full-text` flag that
@@ -122,7 +168,7 @@ the text came from. These provenance fields are written alongside the content:
122
168
 
123
169
  | Field | Description |
124
170
  |-------|-------------|
125
- | `full_text_provider` | Name of the provider that supplied the full text (e.g. `pmc`, `unpaywall`, or a custom name). |
171
+ | `full_text_provider` | Name of the provider that supplied the full text (e.g. `pmc`, `epmc_preprint`, `unpaywall`, or a custom name). |
126
172
  | `oa_status` | Open-access status reported by the provider (e.g. `gold`, `green`, `bronze`). |
127
173
  | `license` | License of the open-access copy, when known. |
128
174
  | `local_pdf_path` | Path (relative to the cache directory) to the downloaded PDF, when one was persisted. |
@@ -7,6 +7,7 @@ from linkml_reference_validator.etl.fulltext.base import (
7
7
 
8
8
  # Import providers to register them
9
9
  from linkml_reference_validator.etl.fulltext.pmc import PMCFullTextProvider
10
+ from linkml_reference_validator.etl.fulltext.epmc_preprint import EuropePMCPreprintProvider
10
11
  from linkml_reference_validator.etl.fulltext.unpaywall import UnpaywallProvider
11
12
  from linkml_reference_validator.etl.fulltext.openalex import OpenAlexProvider
12
13
 
@@ -14,6 +15,7 @@ __all__ = [
14
15
  "FullTextProvider",
15
16
  "FullTextProviderRegistry",
16
17
  "PMCFullTextProvider",
18
+ "EuropePMCPreprintProvider",
17
19
  "UnpaywallProvider",
18
20
  "OpenAlexProvider",
19
21
  ]
@@ -0,0 +1,142 @@
1
+ """Europe PMC preprint full-text provider.
2
+
3
+ Preprints (Europe PMC ``SRC:PPR`` records) are not served by the PMC
4
+ ``fullTextXML`` endpoint and their bodies are absent from PMC even when a PMCID
5
+ exists. The one route that reliably serves a preprint body is the Europe PMC
6
+ ``fulltextRepo`` PDF: every ``SRC:PPR AND HAS_FT:Y`` core record carries a direct
7
+ ``/fulltextRepo?pprId=...`` PDF URL in its ``fullTextUrlList``.
8
+
9
+ This provider resolves that URL from a DOI (or a ``SRC:PPR`` id) and returns it as
10
+ a ``FullTextLocation``. The downstream acquire/extract machinery validates the
11
+ ``%PDF-`` magic bytes (a minority of records return a stale-filename error blob),
12
+ so an HTML/error response is rejected rather than fed to the PDF parser.
13
+ """
14
+
15
+ import logging
16
+ import time
17
+ from typing import Optional
18
+
19
+ import requests # type: ignore
20
+
21
+ from linkml_reference_validator.models import (
22
+ FullTextLocation,
23
+ ReferenceIdentifiers,
24
+ ReferenceValidationConfig,
25
+ )
26
+ from linkml_reference_validator.etl.fulltext.base import (
27
+ FullTextProvider,
28
+ FullTextProviderRegistry,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _EPMC_SEARCH_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
34
+
35
+
36
+ @FullTextProviderRegistry.register
37
+ class EuropePMCPreprintProvider(FullTextProvider):
38
+ """Locate preprint full text via the Europe PMC fulltextRepo PDF route.
39
+
40
+ Examples:
41
+ >>> EuropePMCPreprintProvider.name()
42
+ 'epmc_preprint'
43
+ """
44
+
45
+ @classmethod
46
+ def name(cls) -> str:
47
+ return "epmc_preprint"
48
+
49
+ def locate(
50
+ self, ids: ReferenceIdentifiers, config: ReferenceValidationConfig
51
+ ) -> Optional[FullTextLocation]:
52
+ # Skip the Europe PMC round-trip for records the metadata source already
53
+ # confirmed are peer-reviewed (is_preprint False). A None ("unknown", e.g.
54
+ # a PMID or DataCite record) is still attempted.
55
+ if ids.is_preprint is False:
56
+ return None
57
+
58
+ query = self._build_query(ids)
59
+ if query is None:
60
+ return None
61
+
62
+ time.sleep(config.rate_limit_delay)
63
+ params = {
64
+ "query": query,
65
+ "format": "json",
66
+ "resultType": "core",
67
+ "pageSize": "1",
68
+ "email": config.email,
69
+ }
70
+ response = requests.get(_EPMC_SEARCH_URL, params=params, timeout=30)
71
+ if response.status_code != 200:
72
+ logger.debug(f"Europe PMC search returned {response.status_code} for {query}")
73
+ return None
74
+
75
+ result = self._first_ppr_result(response.json())
76
+ if result is None:
77
+ return None
78
+
79
+ pdf_url = self._extract_pdf_url(result)
80
+ if not pdf_url:
81
+ return None
82
+
83
+ return FullTextLocation(
84
+ url=pdf_url,
85
+ format_hint="pdf",
86
+ oa_status="green",
87
+ license=result.get("license"),
88
+ version="preprint",
89
+ provider="epmc_preprint",
90
+ )
91
+
92
+ def _build_query(self, ids: ReferenceIdentifiers) -> Optional[str]:
93
+ """Build a Europe PMC query restricted to the preprint source.
94
+
95
+ Examples:
96
+ >>> p = EuropePMCPreprintProvider()
97
+ >>> p._build_query(ReferenceIdentifiers(doi="10.1101/x"))
98
+ 'DOI:"10.1101/x" AND SRC:PPR'
99
+ >>> p._build_query(ReferenceIdentifiers(pprid="PPR42"))
100
+ 'EXT_ID:PPR42 AND SRC:PPR'
101
+ >>> p._build_query(ReferenceIdentifiers(pmid="123")) is None
102
+ True
103
+ """
104
+ if ids.doi:
105
+ return f'DOI:"{ids.doi}" AND SRC:PPR'
106
+ if ids.pprid:
107
+ return f"EXT_ID:{ids.pprid} AND SRC:PPR"
108
+ return None
109
+
110
+ def _first_ppr_result(self, data: dict) -> Optional[dict]:
111
+ """Return the first ``SRC:PPR`` result, or None.
112
+
113
+ Restricting to ``source == "PPR"`` guards against a peer-reviewed record
114
+ sharing the DOI namespace being mistaken for a preprint.
115
+ """
116
+ results = data.get("resultList", {}).get("result", [])
117
+ for result in results:
118
+ if isinstance(result, dict) and result.get("source") == "PPR":
119
+ return result
120
+ return None
121
+
122
+ def _extract_pdf_url(self, result: dict) -> Optional[str]:
123
+ """Find the fulltextRepo PDF URL for a preprint core record.
124
+
125
+ Returns the ``documentStyle == "pdf"`` entry from ``fullTextUrlList`` (the
126
+ Europe PMC ``fulltextRepo`` PDF). The full URL must be taken verbatim from
127
+ the record: the working endpoint includes a per-record ``fileName`` query
128
+ parameter that cannot be reconstructed from the preprint id alone (a
129
+ ``fileName``-less request 500s), so a record without a usable PDF entry
130
+ yields no location rather than a guessed URL.
131
+ """
132
+ full_text_urls = result.get("fullTextUrlList", {}).get("fullTextUrl", [])
133
+ for entry in full_text_urls:
134
+ if not isinstance(entry, dict):
135
+ continue
136
+ url = entry.get("url")
137
+ if not url:
138
+ continue
139
+ if entry.get("documentStyle") == "pdf" or "fulltextRepo" in url:
140
+ return url
141
+
142
+ return None
@@ -39,10 +39,12 @@ def build_identifiers(content: ReferenceContent) -> ReferenceIdentifiers:
39
39
  >>> ids = build_identifiers(ReferenceContent(reference_id="PMID:9", doi="10.1/z"))
40
40
  >>> ids.pmid, ids.doi
41
41
  ('9', '10.1/z')
42
+ >>> build_identifiers(ReferenceContent(reference_id="PPR:PPR42")).pprid
43
+ 'PPR42'
42
44
  """
43
45
  prefix, identifier = _split_reference_id(content.reference_id)
44
46
 
45
- ids = ReferenceIdentifiers(doi=content.doi or None)
47
+ ids = ReferenceIdentifiers(doi=content.doi or None, is_preprint=content.is_preprint)
46
48
 
47
49
  if prefix and identifier:
48
50
  upper = prefix.upper()
@@ -50,6 +52,8 @@ def build_identifiers(content: ReferenceContent) -> ReferenceIdentifiers:
50
52
  ids.pmid = identifier
51
53
  elif upper == "PMCID":
52
54
  ids.pmcid = identifier
55
+ elif upper == "PPR":
56
+ ids.pprid = identifier
53
57
  elif upper == "DOI" and not ids.doi:
54
58
  ids.doi = identifier
55
59
  elif prefix.lower() == "url":
@@ -492,6 +492,12 @@ class ReferenceFetcher:
492
492
  for keyword in reference.keywords:
493
493
  lines.append(f"- {self._quote_yaml_value(keyword)}")
494
494
  lines.append(f"content_type: {reference.content_type}")
495
+ if reference.is_preprint is not None:
496
+ lines.append(f"is_preprint: {str(reference.is_preprint).lower()}")
497
+ if reference.peer_review_status:
498
+ lines.append(
499
+ f"peer_review_status: {self._quote_yaml_value(reference.peer_review_status)}"
500
+ )
495
501
  if reference.full_text_attempted:
496
502
  lines.append("full_text_attempted: true")
497
503
  if reference.full_text_provider:
@@ -651,6 +657,8 @@ class ReferenceFetcher:
651
657
  oa_status=frontmatter.get("oa_status"),
652
658
  license=frontmatter.get("license"),
653
659
  local_pdf_path=frontmatter.get("local_pdf_path"),
660
+ is_preprint=frontmatter.get("is_preprint"),
661
+ peer_review_status=frontmatter.get("peer_review_status"),
654
662
  full_text_attempted=bool(frontmatter.get("full_text_attempted", False)),
655
663
  )
656
664
 
@@ -24,6 +24,7 @@ from linkml_reference_validator.etl.sources.base import (
24
24
  # Import sources to register them
25
25
  from linkml_reference_validator.etl.sources.pmid import PMIDSource
26
26
  from linkml_reference_validator.etl.sources.doi import DOISource
27
+ from linkml_reference_validator.etl.sources.ppr import PPRSource
27
28
  from linkml_reference_validator.etl.sources.file import FileSource
28
29
  from linkml_reference_validator.etl.sources.url import URLSource
29
30
  from linkml_reference_validator.etl.sources.entrez import (
@@ -50,6 +51,7 @@ __all__ = [
50
51
  "ReferenceSourceRegistry",
51
52
  "PMIDSource",
52
53
  "DOISource",
54
+ "PPRSource",
53
55
  "FileSource",
54
56
  "URLSource",
55
57
  "GEOSource",
@@ -140,6 +140,8 @@ class DOISource(ReferenceSource):
140
140
  abstract = (abstract + "\n\n" + format_extra_fields_for_content(extra)) if abstract else format_extra_fields_for_content(extra)
141
141
  metadata["extra_fields_captured"] = list(extra.keys())
142
142
 
143
+ is_preprint = self._crossref_preprint_status(message)
144
+
143
145
  return ReferenceContent(
144
146
  reference_id=f"DOI:{doi}",
145
147
  title=title,
@@ -151,8 +153,40 @@ class DOISource(ReferenceSource):
151
153
  doi=doi,
152
154
  keywords=keywords,
153
155
  metadata=metadata,
156
+ is_preprint=is_preprint,
157
+ peer_review_status="preprint" if is_preprint else None,
154
158
  )
155
159
 
160
+ def _crossref_preprint_status(self, message: dict) -> Optional[bool]:
161
+ """Classify a Crossref work as preprint / not-preprint / unknown.
162
+
163
+ Crossref models preprints as ``type: "posted-content"`` (usually with
164
+ ``subtype: "preprint"``). The ``type`` is authoritative, so a known
165
+ non-preprint type returns ``False`` (recorded so a preprint-specific
166
+ full-text provider can skip the record without a wasted lookup). Only an
167
+ entirely typeless record is left ``None`` ("unknown"). A DOI-prefix
168
+ heuristic is deliberately not used because, e.g., the ``10.1101`` prefix
169
+ also covers peer-reviewed Cold Spring Harbor journals.
170
+
171
+ Examples:
172
+ >>> source = DOISource()
173
+ >>> source._crossref_preprint_status({"type": "posted-content", "subtype": "preprint"})
174
+ True
175
+ >>> source._crossref_preprint_status({"type": "posted-content"})
176
+ True
177
+ >>> source._crossref_preprint_status({"type": "journal-article"})
178
+ False
179
+ >>> source._crossref_preprint_status({}) is None
180
+ True
181
+ """
182
+ if message.get("type") == "posted-content":
183
+ return True
184
+ if str(message.get("subtype", "")).lower() == "preprint":
185
+ return True
186
+ if message.get("type"):
187
+ return False
188
+ return None
189
+
156
190
  def _fetch_from_datacite(
157
191
  self, doi: str, config: ReferenceValidationConfig
158
192
  ) -> Optional[ReferenceContent]:
@@ -0,0 +1,159 @@
1
+ """Preprint (Europe PMC ``SRC:PPR``) reference source.
2
+
3
+ Resolves a preprint by its Europe PMC preprint id (``PPR:PPR123456``) to metadata
4
+ via the Europe PMC REST search API. The ``SRC:PPR`` universe is the preprint
5
+ firehose: DOI-bearing but without PMIDs, so these records are not reachable
6
+ through the normal PMID path. Records resolved here are always marked as
7
+ preprints (``is_preprint`` / ``peer_review_status``) so downstream knowledge bases
8
+ can apply "not sole support" policies.
9
+
10
+ Preprint *full text* is fetched separately by the ``epmc_preprint`` full-text
11
+ provider (via the DOI/PPR id crosswalk); this source only supplies metadata and
12
+ the abstract.
13
+
14
+ Examples:
15
+ >>> from linkml_reference_validator.etl.sources.ppr import PPRSource
16
+ >>> PPRSource.prefix()
17
+ 'PPR'
18
+ >>> PPRSource.can_handle("PPR:PPR123456")
19
+ True
20
+ >>> PPRSource.can_handle("DOI:10.1101/x")
21
+ False
22
+ """
23
+
24
+ import logging
25
+ import time
26
+ from typing import Optional
27
+
28
+ import requests # type: ignore
29
+
30
+ from linkml_reference_validator.models import ReferenceContent, ReferenceValidationConfig
31
+ from linkml_reference_validator.etl.sources.base import ReferenceSource, ReferenceSourceRegistry
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _EPMC_SEARCH_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
36
+
37
+
38
+ @ReferenceSourceRegistry.register
39
+ class PPRSource(ReferenceSource):
40
+ """Fetch preprint metadata from Europe PMC by ``SRC:PPR`` id.
41
+
42
+ Examples:
43
+ >>> source = PPRSource()
44
+ >>> source.prefix()
45
+ 'PPR'
46
+ """
47
+
48
+ @classmethod
49
+ def prefix(cls) -> str:
50
+ """Return 'PPR' prefix.
51
+
52
+ Examples:
53
+ >>> PPRSource.prefix()
54
+ 'PPR'
55
+ """
56
+ return "PPR"
57
+
58
+ def fetch(
59
+ self, identifier: str, config: ReferenceValidationConfig
60
+ ) -> Optional[ReferenceContent]:
61
+ """Fetch preprint metadata for a Europe PMC preprint id.
62
+
63
+ Args:
64
+ identifier: Preprint id (with or without the leading ``PPR``).
65
+ config: Configuration including rate limiting and email.
66
+
67
+ Returns:
68
+ ReferenceContent marked as a preprint, or None if not found.
69
+ """
70
+ ppr_id = self._normalize_id(identifier)
71
+ time.sleep(config.rate_limit_delay)
72
+
73
+ params = {
74
+ "query": f"EXT_ID:{ppr_id} AND SRC:PPR",
75
+ "format": "json",
76
+ "resultType": "core",
77
+ "pageSize": "1",
78
+ "email": config.email,
79
+ }
80
+ # External system boundary: a network blip or a non-JSON body must yield a
81
+ # graceful skip (None) for this one reference, as the PMID and
82
+ # ClinicalTrials sources do, rather than aborting the whole validation run.
83
+ try:
84
+ response = requests.get(_EPMC_SEARCH_URL, params=params, timeout=30)
85
+ if response.status_code != 200:
86
+ logger.warning(f"Europe PMC returned {response.status_code} for PPR:{ppr_id}")
87
+ return None
88
+ data = response.json()
89
+ except Exception as exc:
90
+ logger.warning(f"Failed to fetch PPR:{ppr_id} from Europe PMC: {exc}")
91
+ return None
92
+
93
+ result = self._first_ppr_result(data)
94
+ if result is None:
95
+ logger.warning(f"No Europe PMC preprint found for PPR:{ppr_id}")
96
+ return None
97
+
98
+ abstract = result.get("abstractText") or None
99
+ doi = result.get("doi") or None
100
+ year = str(result.get("pubYear")) if result.get("pubYear") else None
101
+
102
+ return ReferenceContent(
103
+ reference_id=f"PPR:{ppr_id}",
104
+ title=result.get("title") or None,
105
+ content=abstract,
106
+ content_type="abstract_only" if abstract else "unavailable",
107
+ authors=self._parse_authors(result.get("authorString")),
108
+ journal=self._extract_journal(result),
109
+ year=year,
110
+ doi=doi,
111
+ is_preprint=True,
112
+ peer_review_status="preprint",
113
+ )
114
+
115
+ def _normalize_id(self, identifier: str) -> str:
116
+ """Normalize a preprint id to Europe PMC's ``PPR`` form.
117
+
118
+ Examples:
119
+ >>> source = PPRSource()
120
+ >>> source._normalize_id("PPR123456")
121
+ 'PPR123456'
122
+ >>> source._normalize_id("123456")
123
+ 'PPR123456'
124
+ >>> source._normalize_id("ppr123456")
125
+ 'PPR123456'
126
+ """
127
+ stripped = identifier.strip()
128
+ if stripped.upper().startswith("PPR"):
129
+ return "PPR" + stripped[3:]
130
+ return f"PPR{stripped}"
131
+
132
+ def _first_ppr_result(self, data: dict) -> Optional[dict]:
133
+ """Return the first ``SRC:PPR`` result, or None."""
134
+ results = data.get("resultList", {}).get("result", [])
135
+ for result in results:
136
+ if isinstance(result, dict) and result.get("source") == "PPR":
137
+ return result
138
+ return None
139
+
140
+ def _parse_authors(self, author_string: Optional[str]) -> Optional[list[str]]:
141
+ """Split a Europe PMC ``authorString`` into individual names.
142
+
143
+ Examples:
144
+ >>> source = PPRSource()
145
+ >>> source._parse_authors("Smith J, Doe A.")
146
+ ['Smith J', 'Doe A']
147
+ >>> source._parse_authors(None) is None
148
+ True
149
+ """
150
+ if not author_string:
151
+ return None
152
+ names = [name.strip().rstrip(".").strip() for name in author_string.split(",")]
153
+ names = [name for name in names if name]
154
+ return names or None
155
+
156
+ def _extract_journal(self, result: dict) -> Optional[str]:
157
+ """Extract the hosting server name (e.g. bioRxiv) from journalInfo."""
158
+ journal = result.get("journalInfo", {}).get("journal", {}).get("title")
159
+ return journal or None
@@ -462,11 +462,12 @@ class ReferenceValidationConfig(BaseModel):
462
462
  ),
463
463
  )
464
464
  full_text_providers: list[str] = Field(
465
- default_factory=lambda: ["pmc", "unpaywall", "openalex"],
465
+ default_factory=lambda: ["pmc", "epmc_preprint", "unpaywall", "openalex"],
466
466
  description=(
467
467
  "Ordered list of full-text provider names to try until one yields usable "
468
- "full text. Names map to built-in providers (pmc, unpaywall, openalex) or "
469
- "custom providers loaded from YAML."
468
+ "full text. Names map to built-in providers (pmc, epmc_preprint, unpaywall, "
469
+ "openalex) or custom providers loaded from YAML. The epmc_preprint provider "
470
+ "fetches preprint body text via the Europe PMC fulltextRepo PDF route."
470
471
  ),
471
472
  )
472
473
  pdf_backend: str = Field(
@@ -625,12 +626,19 @@ class ReferenceIdentifiers:
625
626
  '10.1038/x'
626
627
  >>> ids.pmcid is None
627
628
  True
629
+ >>> ReferenceIdentifiers(pprid="PPR123456").pprid
630
+ 'PPR123456'
628
631
  """
629
632
 
630
633
  doi: Optional[str] = None
631
634
  pmid: Optional[str] = None
632
635
  pmcid: Optional[str] = None
633
636
  url: Optional[str] = None
637
+ pprid: Optional[str] = None # Europe PMC preprint id (SRC:PPR), e.g. "PPR123456"
638
+ # Carried from the metadata source so a preprint-specific provider can skip a
639
+ # record already known to be peer-reviewed (False) without a wasted lookup.
640
+ # None means "unknown", which providers must still attempt.
641
+ is_preprint: Optional[bool] = None
634
642
 
635
643
 
636
644
  @dataclass
@@ -679,6 +687,16 @@ class ReferenceContent:
679
687
  ... )
680
688
  >>> len(ref.supplementary_files)
681
689
  1
690
+ >>> ref = ReferenceContent(
691
+ ... reference_id="DOI:10.1101/2024.01.01.573333",
692
+ ... title="A preprint",
693
+ ... is_preprint=True,
694
+ ... peer_review_status="preprint",
695
+ ... )
696
+ >>> ref.is_preprint
697
+ True
698
+ >>> ref.peer_review_status
699
+ 'preprint'
682
700
  """
683
701
 
684
702
  reference_id: str
@@ -697,6 +715,13 @@ class ReferenceContent:
697
715
  oa_status: Optional[str] = None
698
716
  license: Optional[str] = None
699
717
  local_pdf_path: Optional[str] = None
718
+ # Preprint / peer-review status, surfaced so downstream KBs can apply policies
719
+ # such as "a preprint may not be the sole support for a claim". Left as None
720
+ # when the publication type is unknown; only asserted when positively detected
721
+ # (e.g. Crossref type "posted-content"/subtype "preprint", or a Europe PMC
722
+ # SRC:PPR record).
723
+ is_preprint: Optional[bool] = None
724
+ peer_review_status: Optional[str] = None # e.g. "preprint", "peer_reviewed"
700
725
  # True once the full-text chain has been run to a clean (error-free) conclusion
701
726
  # for this record. Distinguishes "the abstract is all that exists" from "we
702
727
  # haven't successfully tried yet", so a transient outage isn't cached forever.