linkml-reference-validator 0.3.0rc1__tar.gz → 0.3.0rc3__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 (220) hide show
  1. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/PKG-INFO +1 -1
  2. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/pmc.py +49 -3
  3. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/reference_fetcher.py +33 -8
  4. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/pmid.py +2 -3
  5. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/models.py +26 -0
  6. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_no_cache_downgrade.py +15 -1
  7. linkml_reference_validator-0.3.0rc3/tests/test_pmc_modern_container.py +75 -0
  8. linkml_reference_validator-0.3.0rc3/tests/test_serve_stale_html_optin.py +134 -0
  9. linkml_reference_validator-0.3.0rc3/tests/test_trust_cached_entries.py +81 -0
  10. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.claude/.mcp.json +0 -0
  11. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.claude/settings.json +0 -0
  12. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.copier-answers.yml +0 -0
  13. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.devcontainer/devcontainer.json +0 -0
  14. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.devcontainer/post_create.sh +0 -0
  15. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.editorconfig +0 -0
  16. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/actions/claude-code-action/action.yml +0 -0
  17. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/actions/claude-issue-summarize-action/action.yml +0 -0
  18. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/actions/claude-issue-triage-action/action.yml +0 -0
  19. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/ai-controllers.json +0 -0
  20. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/copilot-instructions.md +0 -0
  21. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/copilot-setup-steps.yml +0 -0
  22. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/dependabot.yml +0 -0
  23. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/claude-code-review.yml +0 -0
  24. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/claude-issue-summarize.yml +0 -0
  25. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/claude-issue-triage.yml +0 -0
  26. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/claude-ontology-review.yml +0 -0
  27. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/claude.yml +0 -0
  28. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/deploy-docs.yaml +0 -0
  29. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/dragon-ai.yml +0 -0
  30. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/main.yaml +0 -0
  31. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.github/workflows/pypi-publish.yaml +0 -0
  32. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.gitignore +0 -0
  33. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.goosehints +0 -0
  34. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.pre-commit-config.yaml +0 -0
  35. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/.yamllint.yaml +0 -0
  36. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/AGENTS.md +0 -0
  37. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/BUG_GEO_ACCESSION_TO_UID.md +0 -0
  38. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/BUG_ZENODO_DOI_AND_URL_HANDLING.md +0 -0
  39. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/CLAUDE.md +0 -0
  40. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/CLI-ISSUES.md +0 -0
  41. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/CODE_OF_CONDUCT.md +0 -0
  42. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/CONTRIBUTING.md +0 -0
  43. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/FEATURE_REQUEST_REPAIR.md +0 -0
  44. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/LICENSE +0 -0
  45. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/README.md +0 -0
  46. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/REVIEW2.md +0 -0
  47. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/REVIEW3.md +0 -0
  48. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/ai.just +0 -0
  49. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/copilot-instructions.md +0 -0
  50. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/about.md +0 -0
  51. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/assets/zotero-find-full-text.png +0 -0
  52. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/concepts/content-types.md +0 -0
  53. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/concepts/editorial-conventions.md +0 -0
  54. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/concepts/how-it-works.md +0 -0
  55. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/elements/.gitkeep +0 -0
  56. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/add-reference-source.md +0 -0
  57. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/export-cache-to-zotero.md +0 -0
  58. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/fetch-full-text-and-pdfs.md +0 -0
  59. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/repair-validation-errors.md +0 -0
  60. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/skip-unsupported-references.md +0 -0
  61. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/use-local-files-and-urls.md +0 -0
  62. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/validate-clinical-trials.md +0 -0
  63. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/validate-dois.md +0 -0
  64. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/validate-entrez.md +0 -0
  65. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/validate-obo-files.md +0 -0
  66. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/validate-titles.md +0 -0
  67. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/how-to/validate-urls.md +0 -0
  68. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/index.md +0 -0
  69. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/notebooks/01_getting_started.ipynb +0 -0
  70. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/notebooks/02_advanced_usage.ipynb +0 -0
  71. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/notebooks/03_python_api.ipynb +0 -0
  72. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/notebooks/04_obo_validation.ipynb +0 -0
  73. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/notebooks/05_geo_validation.ipynb +0 -0
  74. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/quickstart.md +0 -0
  75. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/reference/cli.md +0 -0
  76. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/reference/splitting.md +0 -0
  77. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/setup-guide.md +0 -0
  78. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/superpowers/plans/2026-06-12-modular-pdf-fulltext-fetching.md +0 -0
  79. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/superpowers/plans/2026-08-05-private-library-full-text.md +0 -0
  80. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/superpowers/specs/2026-06-12-modular-pdf-fulltext-fetching-design.md +0 -0
  81. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/superpowers/specs/2026-08-05-private-library-full-text-design.md +0 -0
  82. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/templates-linkml/README.md +0 -0
  83. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/todo.md +0 -0
  84. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/troubleshooting.md +0 -0
  85. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/docs/tutorials/complete-workflow.md +0 -0
  86. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/examples/README.md +0 -0
  87. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/justfile +0 -0
  88. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/mkdocs.yml +0 -0
  89. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/mypy.ini +0 -0
  90. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/notebooks/01_getting_started.ipynb +0 -0
  91. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/notebooks/02_advanced_usage.ipynb +0 -0
  92. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/notebooks/03_python_api.ipynb +0 -0
  93. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/notebooks/README.md +0 -0
  94. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/project/README.md +0 -0
  95. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/project.justfile +0 -0
  96. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/pyproject.toml +0 -0
  97. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/pytest.ini +0 -0
  98. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/__init__.py +0 -0
  99. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/_version.py +0 -0
  100. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/cli/__init__.py +0 -0
  101. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/cli/cache.py +0 -0
  102. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/cli/lookup.py +0 -0
  103. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/cli/repair.py +0 -0
  104. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/cli/shared.py +0 -0
  105. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/cli/validate.py +0 -0
  106. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/__init__.py +0 -0
  107. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/acquire.py +0 -0
  108. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/csl_export.py +0 -0
  109. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/extract/__init__.py +0 -0
  110. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/extract/base.py +0 -0
  111. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/extract/html.py +0 -0
  112. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/extract/pdf.py +0 -0
  113. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/extract/xml.py +0 -0
  114. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/__init__.py +0 -0
  115. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/base.py +0 -0
  116. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/epmc_preprint.py +0 -0
  117. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/json_api.py +0 -0
  118. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/loader.py +0 -0
  119. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/openalex.py +0 -0
  120. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/unpaywall.py +0 -0
  121. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/fulltext/zotero.py +0 -0
  122. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/identifiers.py +0 -0
  123. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/__init__.py +0 -0
  124. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/base.py +0 -0
  125. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/clinicaltrials.py +0 -0
  126. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/doi.py +0 -0
  127. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/entrez.py +0 -0
  128. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/file.py +0 -0
  129. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/json_api.py +0 -0
  130. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/loader.py +0 -0
  131. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/ppr.py +0 -0
  132. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/url.py +0 -0
  133. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/sources/utils.py +0 -0
  134. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/etl/text_extractor.py +0 -0
  135. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/field_detection.py +0 -0
  136. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/matching.py +0 -0
  137. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/plugins/REVIEW.md +0 -0
  138. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/plugins/__init__.py +0 -0
  139. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/plugins/reference_validation_plugin.py +0 -0
  140. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/validation/__init__.py +0 -0
  141. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/validation/fuzzy_text_utils.py +0 -0
  142. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/validation/repairer.py +0 -0
  143. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/src/linkml_reference_validator/validation/supporting_text_validator.py +0 -0
  144. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/__init__.py +0 -0
  145. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/conftest.py +0 -0
  146. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/data/test_data_deep_nesting_invalid.yaml +0 -0
  147. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/data/test_data_deep_nesting_valid.yaml +0 -0
  148. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/data/test_data_invalid.yaml +0 -0
  149. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/data/test_data_valid.yaml +0 -0
  150. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/data/test_schema.yaml +0 -0
  151. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/data/test_schema_deep_nesting.yaml +0 -0
  152. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/GEO_GSE12345.md +0 -0
  153. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMC_TEST001.md +0 -0
  154. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMC_TEST001.txt +0 -0
  155. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMC_TEST002.md +0 -0
  156. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMC_TEST002.txt +0 -0
  157. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMID_TEST001.md +0 -0
  158. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMID_TEST001.txt +0 -0
  159. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMID_TEST002.md +0 -0
  160. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMID_TEST002.txt +0 -0
  161. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMID_TEST003.md +0 -0
  162. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/PMID_TEST003.txt +0 -0
  163. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/clinicaltrials_NCT00000001.md +0 -0
  164. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/fulltext_html/README.md +0 -0
  165. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/fulltext_html/dspace-32894695.html +0 -0
  166. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/fulltext_html/iris-19049553.html +0 -0
  167. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/fulltext_html/plos-0000308.html +0 -0
  168. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/jats/PMC5593426.xml +0 -0
  169. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/jats/README.md +0 -0
  170. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/fixtures/sample.obo +0 -0
  171. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_absent_abstract_recovery.py +0 -0
  172. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_acquire.py +0 -0
  173. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_cache_author_recovery.py +0 -0
  174. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_cli.py +0 -0
  175. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_cli_cache_enrich.py +0 -0
  176. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_cli_cache_export.py +0 -0
  177. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_cli_cache_reference.py +0 -0
  178. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_cli_lookup.py +0 -0
  179. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_cli_repair.py +0 -0
  180. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_clinicaltrials_cache.py +0 -0
  181. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_clinicaltrials_integration.py +0 -0
  182. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_deep_nesting.py +0 -0
  183. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_doi_preprint.py +0 -0
  184. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_e2e_integration.py +0 -0
  185. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_empty_snippets.py +0 -0
  186. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_epmc_preprint_provider.py +0 -0
  187. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_extractor_fidelity.py +0 -0
  188. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_extractors.py +0 -0
  189. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_field_detection.py +0 -0
  190. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_frontmatter_and_doi_case.py +0 -0
  191. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_fulltext_loader.py +0 -0
  192. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_fulltext_providers.py +0 -0
  193. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_fuzzy_suggestions.py +0 -0
  194. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_geo_integration.py +0 -0
  195. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_html_fulltext_acceptance.py +0 -0
  196. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_identifiers.py +0 -0
  197. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_jats_tables.py +0 -0
  198. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_json_api_source.py +0 -0
  199. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_ligature_normalization.py +0 -0
  200. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_models.py +0 -0
  201. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_open_access_policy.py +0 -0
  202. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_optional_linkml_dependency.py +0 -0
  203. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_plugin_integration.py +0 -0
  204. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_pmc_fulltext.py +0 -0
  205. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_pmid_network.py +0 -0
  206. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_ppr_source.py +0 -0
  207. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_real_validation.py +0 -0
  208. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_reference_fetcher.py +0 -0
  209. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_repair.py +0 -0
  210. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_source_utils.py +0 -0
  211. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_sources.py +0 -0
  212. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_split_supporting_text.py +0 -0
  213. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_supporting_text_validator.py +0 -0
  214. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_text_extractor.py +0 -0
  215. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_title_validation.py +0 -0
  216. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_unknown_slot_traversal.py +0 -0
  217. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_validation_config.py +0 -0
  218. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_validation_counts.py +0 -0
  219. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/tests/test_zotero_provider.py +0 -0
  220. {linkml_reference_validator-0.3.0rc1 → linkml_reference_validator-0.3.0rc3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: linkml_reference_validator
3
- Version: 0.3.0rc1
3
+ Version: 0.3.0rc3
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
@@ -6,7 +6,7 @@ fetched from the PMC XML API (with an HTML fallback) and extracted via XMLExtrac
6
6
 
7
7
  import logging
8
8
  import time
9
- from typing import Optional, Union
9
+ from typing import Any, Optional, Union
10
10
 
11
11
  from Bio import Entrez # type: ignore
12
12
  from bs4 import BeautifulSoup # type: ignore
@@ -38,6 +38,51 @@ class TransientFullTextError(RuntimeError):
38
38
  """
39
39
 
40
40
 
41
+ #: Classes PMC has used for the article body. ``main-article-body`` is what it
42
+ #: serves now, on a ``<section>``; the two ``div`` classes are the older markup
43
+ #: and are kept so entries cached against them still re-extract.
44
+ #:
45
+ #: Deliberately not ``body``, which PMC pairs with ``main-article-body`` on the
46
+ #: same element. Matching it alone would match every page's ``<body>``, and the
47
+ #: structural test is the only thing standing between this fetch and caching a
48
+ #: bot-check interstitial served on an HTTP 200.
49
+ #:
50
+ #: Order is a priority order, not an alphabetical one: the first match wins, so
51
+ #: the current wrapper is tried before the legacy ones. It matters for a legacy
52
+ #: page carrying several ``tsec`` sections, where only the first is returned --
53
+ #: the previous code behaved the same way, so this is a known limit rather than
54
+ #: a regression, but reordering the tuple would change which section that is.
55
+ PMC_ARTICLE_BODY_CLASSES = ("main-article-body", "article-body", "tsec")
56
+
57
+
58
+ def find_pmc_article_body(soup: BeautifulSoup) -> Optional[Any]:
59
+ """Return PMC's article-body element, or None if the page has none.
60
+
61
+ Matches on class rather than element name: the container moved from ``div``
62
+ to ``section`` when PMC changed its markup, so pinning the tag is what broke
63
+ the previous version for every article.
64
+
65
+ Args:
66
+ soup: A parsed PMC article page
67
+
68
+ Returns:
69
+ The article-body element, or None
70
+
71
+ Examples:
72
+ >>> from bs4 import BeautifulSoup
73
+ >>> html = '<section class="body main-article-body"><p>Text.</p></section>'
74
+ >>> find_pmc_article_body(BeautifulSoup(html, "html.parser")) is not None
75
+ True
76
+ >>> find_pmc_article_body(BeautifulSoup("<body><p>x</p></body>", "html.parser")) is None
77
+ True
78
+ """
79
+ for class_name in PMC_ARTICLE_BODY_CLASSES:
80
+ found = soup.find(class_=class_name)
81
+ if found is not None:
82
+ return found
83
+ return None
84
+
85
+
41
86
  @FullTextProviderRegistry.register
42
87
  class PMCFullTextProvider(FullTextProvider):
43
88
  """Provide PMC full text for a reference identified by PMID/PMCID.
@@ -132,7 +177,8 @@ class PMCFullTextProvider(FullTextProvider):
132
177
 
133
178
  This is the one page fetch the OA providers' "a landing page is not full
134
179
  text" rule does not cover, and what makes it safe is the requirement
135
- below: text is returned only from a ``div.article-body`` or ``div.tsec``.
180
+ below: text is returned only from an element carrying one of
181
+ :data:`PMC_ARTICLE_BODY_CLASSES`.
136
182
  A bot-check interstitial -- which PMC serves on an HTTP 200, so no status
137
183
  check sees it -- carries neither, so this yields ``None`` rather than
138
184
  caching the page. The ``oa_url`` fallback that was removed had no such
@@ -152,7 +198,7 @@ class PMCFullTextProvider(FullTextProvider):
152
198
  )
153
199
 
154
200
  soup = BeautifulSoup(response.content, "html.parser")
155
- article_body = soup.find("div", class_="article-body") or soup.find("div", class_="tsec")
201
+ article_body = find_pmc_article_body(soup)
156
202
  if article_body:
157
203
  # The region is selected here, but the text comes out of the shared
158
204
  # extractor rather than a private copy of the paragraph walk, so
@@ -343,7 +343,18 @@ class ReferenceFetcher:
343
343
 
344
344
  # Check disk cache
345
345
  if not force_refresh:
346
- cached = self._load_from_disk(normalized_reference_id)
346
+ cached = self._load_from_disk(
347
+ normalized_reference_id,
348
+ allow_stale=self.config.trust_cached_entries,
349
+ allow_stale_html=self.config.trust_cached_entries,
350
+ )
351
+ if cached and self.config.trust_cached_entries:
352
+ # Trusted: serve what is on disk. No staleness re-fetch and no
353
+ # full-text retry, so nothing can shorten the body or replace it
354
+ # with a bot-check page.
355
+ return self._remember(
356
+ normalized_reference_id, FetchOutcome(content=cached)
357
+ )
347
358
  if cached:
348
359
  # A record cached as abstract_only may predate full-text support, or
349
360
  # reflect a prior transient failure. Give the chain one more chance
@@ -424,7 +435,11 @@ class ReferenceFetcher:
424
435
  if force_refresh:
425
436
  return FetchOutcome(content=None)
426
437
 
427
- stale = self._load_from_disk(normalized_reference_id, allow_stale=True)
438
+ stale = self._load_from_disk(
439
+ normalized_reference_id,
440
+ allow_stale=True,
441
+ allow_stale_html=self.config.serve_stale_html_full_text,
442
+ )
428
443
  if stale is None:
429
444
  return FetchOutcome(content=None)
430
445
 
@@ -517,10 +532,17 @@ class ReferenceFetcher:
517
532
  cached.content_type,
518
533
  )
519
534
 
520
- # May its text be served? Asked without the bypass, so stale HTML is
521
- # withheld here exactly as _stale_fallback withholds it, and validation
522
- # falls back to the freshly fetched abstract.
523
- servable = self._load_from_disk(normalized_reference_id, allow_stale=True)
535
+ # May its text be served? Asked with the consumer's serve policy, the
536
+ # same one _stale_fallback uses. By default stale HTML is withheld and
537
+ # validation falls back to the freshly fetched abstract; a consumer that
538
+ # has opted in gets the cached body instead. This is the path a refresh
539
+ # normally takes -- the source still answers, with an abstract -- so it
540
+ # is the one most consumers see, not _stale_fallback.
541
+ servable = self._load_from_disk(
542
+ normalized_reference_id,
543
+ allow_stale=True,
544
+ allow_stale_html=self.config.serve_stale_html_full_text,
545
+ )
524
546
  return FetchOutcome(content=servable or fresh, served_stale=True)
525
547
 
526
548
  def _report_shrinking_refresh(
@@ -1528,8 +1550,11 @@ class ReferenceFetcher:
1528
1550
  ):
1529
1551
  logger.warning(
1530
1552
  "Refusing stale HTML full text for %s: it may be a repository "
1531
- "landing page. Retry when the source serves full text again to "
1532
- "repair it.",
1553
+ "landing page. A re-fetch repairs it where the source still "
1554
+ "serves full text -- but not for a PMC-only article, which "
1555
+ "answers a Python HTTP client with a bot-check interstitial on "
1556
+ "an HTTP 200. Set serve_stale_html_full_text if this cache is "
1557
+ "one you review.",
1533
1558
  reference_id,
1534
1559
  )
1535
1560
  return None
@@ -23,6 +23,7 @@ from Bio import Entrez # type: ignore
23
23
  from bs4 import BeautifulSoup # type: ignore
24
24
  import requests # type: ignore
25
25
 
26
+ from linkml_reference_validator.etl.fulltext.pmc import find_pmc_article_body
26
27
  from linkml_reference_validator.models import ReferenceContent, ReferenceValidationConfig
27
28
  from linkml_reference_validator.etl.extract.html import HTMLExtractor
28
29
  from linkml_reference_validator.etl.extract import MIN_FULLTEXT_CHARS
@@ -582,9 +583,7 @@ class PMIDSource(ReferenceSource):
582
583
  return None
583
584
 
584
585
  soup = BeautifulSoup(response.content, "html.parser")
585
- article_body = soup.find("div", class_="article-body") or soup.find(
586
- "div", class_="tsec"
587
- )
586
+ article_body = find_pmc_article_body(soup)
588
587
 
589
588
  if article_body:
590
589
  # Region selected here, text extracted by the shared extractor, for
@@ -510,6 +510,32 @@ class ReferenceValidationConfig(BaseModel):
510
510
  "when a metadata source does not already return full text."
511
511
  ),
512
512
  )
513
+ serve_stale_html_full_text: bool = Field(
514
+ default=False,
515
+ description=(
516
+ "If True, serve a stale full_text_html cache entry as evidence instead "
517
+ "of withholding it. Off by default: a pre-stamp HTML entry may be a "
518
+ "publisher landing page rather than the article, and the default "
519
+ "assumes a re-fetch can repair it. Set this only for a cache you "
520
+ "control and review -- for a PMC-only article the repair route is "
521
+ "unreachable (PMC answers a Python HTTP client with a bot-check "
522
+ "interstitial on an HTTP 200), so the guard withholds text that "
523
+ "cannot be recovered. Affects serving only, never overwriting, and "
524
+ "the entry is left un-stamped so a later run still retries."
525
+ ),
526
+ )
527
+ trust_cached_entries: bool = Field(
528
+ default=False,
529
+ description=(
530
+ "If True, use a cache entry as it is on disk and never re-fetch it "
531
+ "because an extractor stamp is missing or old. Off by default, so "
532
+ "caches still migrate. Set it for a committed, reviewed cache, where "
533
+ "a re-fetch is not an improvement but a risk: it can shorten a body, "
534
+ "cache a bot-check page, or replace text a curator quoted. Imperfect "
535
+ "cached text is a content problem to fix deliberately, not on every "
536
+ "validation run."
537
+ ),
538
+ )
513
539
  full_text_providers: list[str] = Field(
514
540
  default_factory=lambda: ["pmc", "epmc_preprint", "unpaywall", "openalex"],
515
541
  description=(
@@ -76,7 +76,21 @@ def _write_cache(cache_dir, reference_id, content_type, body, stamped=False):
76
76
 
77
77
  @pytest.fixture
78
78
  def fetcher(tmp_path):
79
- return ReferenceFetcher(ReferenceValidationConfig(cache_dir=tmp_path, email="me@example.org"))
79
+ """A fetcher with the full-text chain off.
80
+
81
+ These tests are about what a *refresh that loses full text* does to the
82
+ cached file, so the chain has to be absent rather than merely expected to
83
+ fail. With it on they reach the network: the PMC HTML fallback fires and the
84
+ outcome depends on what PMC serves for PMC21078 that minute. They passed for
85
+ a while only because the fallback's container selector matched nothing at
86
+ all -- fixing that (dismech#12672) turned two of them red, which is the test
87
+ depending on a bug rather than the bug being load-bearing.
88
+ """
89
+ return ReferenceFetcher(
90
+ ReferenceValidationConfig(
91
+ cache_dir=tmp_path, email="me@example.org", fetch_full_text=False
92
+ )
93
+ )
80
94
 
81
95
 
82
96
  def _fetch_returning(fetcher, content_type, body):
@@ -0,0 +1,75 @@
1
+ """PMC's current article markup must be recognised by the HTML fallback.
2
+
3
+ The fallback looked for ``div.article-body`` or ``div.tsec``. PMC serves neither
4
+ today: the article sits in ``<section class="body main-article-body">`` -- a
5
+ different element *and* a different class. So the path declined for every PMC
6
+ article, and the "retry when the source serves full text again to repair it"
7
+ advice printed when a stale ``full_text_html`` entry is withheld pointed at a
8
+ route that could never succeed (reported downstream as monarch-initiative/dismech#12672).
9
+
10
+ The structural test is what makes this fetch safe -- a bot-check interstitial
11
+ arrives on an HTTP 200 and carries no article container -- so widening the
12
+ selector must not widen it to something an interstitial or a navigation page
13
+ would satisfy.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+ from bs4 import BeautifulSoup
20
+
21
+ from linkml_reference_validator.etl.fulltext.pmc import find_pmc_article_body
22
+
23
+ BODY = "<p>" + ("Real article prose. " * 40) + "</p>"
24
+
25
+
26
+ @pytest.mark.parametrize(
27
+ "markup,label",
28
+ [
29
+ (f'<section class="body main-article-body">{BODY}</section>', "current PMC"),
30
+ (f'<div class="article-body">{BODY}</div>', "legacy div.article-body"),
31
+ (f'<div class="tsec">{BODY}</div>', "legacy div.tsec"),
32
+ (f'<section class="main-article-body">{BODY}</section>', "class without 'body'"),
33
+ ],
34
+ )
35
+ def test_article_containers_are_found(markup, label):
36
+ soup = BeautifulSoup(markup, "html.parser")
37
+ assert find_pmc_article_body(soup) is not None, f"{label} should be recognised"
38
+
39
+
40
+ @pytest.mark.parametrize(
41
+ "markup,label",
42
+ [
43
+ ("<html><body><p>Checking your browser before accessing.</p></body></html>",
44
+ "bot-check interstitial"),
45
+ ("<html><body><nav><a href='/'>Home</a></nav></body></html>", "navigation page"),
46
+ ("<html><body><div class='usa-banner'>An official website</div></body></html>",
47
+ "banner chrome only"),
48
+ ],
49
+ )
50
+ def test_pages_without_an_article_container_are_declined(markup, label):
51
+ soup = BeautifulSoup(markup, "html.parser")
52
+ assert find_pmc_article_body(soup) is None, f"{label} must not be accepted"
53
+
54
+
55
+ def test_a_bare_body_element_is_not_an_article_container():
56
+ """``<body>`` is on every page, so matching class="body" alone would accept
57
+ an interstitial. Only the article-body classes count."""
58
+ soup = BeautifulSoup(f"<html><body>{BODY}</body></html>", "html.parser")
59
+ assert find_pmc_article_body(soup) is None
60
+
61
+
62
+ def test_a_challenge_page_carrying_a_classed_body_is_still_declined():
63
+ """The interstitial arrives on an HTTP 200, so this selector is the only check.
64
+
65
+ PMC's challenge page carries a classed ``<body>``. None of its classes may
66
+ overlap the article-body list, or a bot-check page gets cached as full text.
67
+ """
68
+ markup = (
69
+ '<html><body class="usa-page bot-check">'
70
+ "<h1>Checking your browser before accessing</h1>"
71
+ "<p>Enable JavaScript and cookies to continue.</p>"
72
+ "</body></html>"
73
+ )
74
+ soup = BeautifulSoup(markup, "html.parser")
75
+ assert find_pmc_article_body(soup) is None
@@ -0,0 +1,134 @@
1
+ """A consumer with a reviewed, committed cache can opt into serving stale HTML.
2
+
3
+ Refusing stale ``full_text_html`` is right by default: a pre-fix entry may be a
4
+ publisher landing page, and #12009 found exactly that stored as full text. But
5
+ the refusal assumes the entry can be repaired by re-fetching, and for a
6
+ PMC-only article it cannot -- PMC answers a Python HTTP client with a bot-check
7
+ interstitial on an HTTP 200 regardless of headers, so the advertised repair route
8
+ is unreachable.
9
+
10
+ For a consumer whose cache is committed and reviewed, that turns a guard into
11
+ data loss: every snippet quoting the body of any pre-stamp HTML entry fails, on
12
+ files the pull request never touched (monarch-initiative/dismech#12672 --
13
+ 1,654 such files, ~294 of them with a snippet that is not in the abstract).
14
+
15
+ ``serve_stale_html_full_text`` lets that consumer say so explicitly. It changes
16
+ only what may be *served*; nothing about overwriting, and nothing by default.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from unittest.mock import patch
22
+
23
+ from linkml_reference_validator.etl.reference_fetcher import ReferenceFetcher
24
+ from linkml_reference_validator.models import (
25
+ ReferenceContent,
26
+ ReferenceValidationConfig,
27
+ )
28
+
29
+ BODY_SENTENCE = "A sentence that exists only in the article body."
30
+
31
+
32
+ def _cache(tmp_path):
33
+ (tmp_path / "PMID_30929739.md").write_text(
34
+ "---\nreference_id: PMID:30929739\ntitle: A paper\n"
35
+ f"content_type: full_text_html\n---\n\n## Content\n\n{BODY_SENTENCE}\n",
36
+ encoding="utf-8",
37
+ )
38
+
39
+
40
+ def _fetch(tmp_path, **cfg):
41
+ _cache(tmp_path)
42
+ fetcher = ReferenceFetcher(
43
+ ReferenceValidationConfig(
44
+ cache_dir=tmp_path, email="me@example.org", fetch_full_text=False, **cfg
45
+ )
46
+ )
47
+
48
+ class _Source:
49
+ def fetch(self, identifier, config):
50
+ return None # the source yields nothing: the stale-fallback path
51
+
52
+ with patch(
53
+ "linkml_reference_validator.etl.reference_fetcher"
54
+ ".ReferenceSourceRegistry.get_source",
55
+ return_value=_Source,
56
+ ):
57
+ return fetcher.fetch("PMID:30929739")
58
+
59
+
60
+ def test_stale_html_is_withheld_by_default(tmp_path):
61
+ """The default must not change: an unrepaired entry is still not quotable."""
62
+ assert _fetch(tmp_path) is None
63
+
64
+
65
+ def test_stale_html_is_served_when_the_consumer_opts_in(tmp_path):
66
+ content = _fetch(tmp_path, serve_stale_html_full_text=True)
67
+ assert content is not None, "opting in must make the cached body servable"
68
+ assert BODY_SENTENCE in (content.content or "")
69
+
70
+
71
+ def test_opting_in_does_not_stamp_or_rewrite_the_entry(tmp_path):
72
+ """Serving is not migrating. The entry stays stale so a later run retries."""
73
+ _fetch(tmp_path, serve_stale_html_full_text=True)
74
+ text = (tmp_path / "PMID_30929739.md").read_text(encoding="utf-8")
75
+ assert "extractor_version" not in text
76
+ assert BODY_SENTENCE in text
77
+
78
+
79
+ def _fetch_with_abstract_source(tmp_path, **cfg):
80
+ """The path a refresh normally takes: the source answers, with an abstract.
81
+
82
+ ``_stale_fallback`` needs the source to yield *nothing*. For a PMC-only
83
+ article PubMed still returns the abstract, so the refresh reaches
84
+ ``_preserve_cached_full_text`` instead: the overwrite is refused, and then a
85
+ separate question decides what may be served. That second question is the
86
+ one the flag has to answer, and it is the one dismech actually hits.
87
+ """
88
+ _cache(tmp_path)
89
+ fetcher = ReferenceFetcher(
90
+ ReferenceValidationConfig(
91
+ cache_dir=tmp_path, email="me@example.org", fetch_full_text=False, **cfg
92
+ )
93
+ )
94
+ fresh = ReferenceContent(
95
+ reference_id="PMID:30929739",
96
+ content="Just the abstract.",
97
+ content_type="abstract_only",
98
+ title="A paper",
99
+ )
100
+
101
+ class _Source:
102
+ def fetch(self, identifier, config):
103
+ return fresh
104
+
105
+ with patch(
106
+ "linkml_reference_validator.etl.reference_fetcher"
107
+ ".ReferenceSourceRegistry.get_source",
108
+ return_value=_Source,
109
+ ):
110
+ return fetcher.fetch("PMID:30929739")
111
+
112
+
113
+ def test_refused_overwrite_serves_the_abstract_by_default(tmp_path):
114
+ content = _fetch_with_abstract_source(tmp_path)
115
+ assert content is not None
116
+ assert BODY_SENTENCE not in (content.content or ""), (
117
+ "by default the cached body stays withheld even though it was preserved"
118
+ )
119
+ assert "Just the abstract." in (content.content or "")
120
+
121
+
122
+ def test_refused_overwrite_serves_the_cached_body_when_opted_in(tmp_path):
123
+ content = _fetch_with_abstract_source(tmp_path, serve_stale_html_full_text=True)
124
+ assert content is not None
125
+ assert BODY_SENTENCE in (content.content or "")
126
+
127
+
128
+ def test_refused_overwrite_leaves_the_file_alone_either_way(tmp_path):
129
+ """Serving is not overwriting: the entry keeps its body and stays un-stamped."""
130
+ for flag in (False, True):
131
+ _fetch_with_abstract_source(tmp_path, serve_stale_html_full_text=flag)
132
+ text = (tmp_path / "PMID_30929739.md").read_text(encoding="utf-8")
133
+ assert BODY_SENTENCE in text, f"body lost with flag={flag}"
134
+ assert "extractor_version" not in text, f"entry stamped with flag={flag}"
@@ -0,0 +1,81 @@
1
+ """A committed cache can be used as it is, without a re-fetch on every run.
2
+
3
+ Staleness re-fetching is right for a cache the tool owns and can rebuild. For a
4
+ cache that is committed and reviewed it inverts: the re-fetch is the risk, not
5
+ the repair. It can shorten a body (dismech#12879), replace an entry with a
6
+ bot-check page served on an HTTP 200 (dismech#12867), or withhold text that
7
+ nothing can recover (dismech#12672).
8
+
9
+ `trust_cached_entries` says use the file. Off by default, so caches still
10
+ migrate for consumers that want that.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from unittest.mock import patch
16
+
17
+ import pytest
18
+
19
+ from linkml_reference_validator.etl.reference_fetcher import ReferenceFetcher
20
+ from linkml_reference_validator.models import ReferenceValidationConfig
21
+
22
+ BODY = "A sentence a curator quoted, present only in this cached body."
23
+
24
+
25
+ def _cache(tmp_path, content_type="full_text_html"):
26
+ (tmp_path / "PMID_20301575.md").write_text(
27
+ "---\nreference_id: PMID:20301575\ntitle: A paper\n"
28
+ f"content_type: {content_type}\n---\n\n## Content\n\n{BODY}\n",
29
+ encoding="utf-8",
30
+ )
31
+
32
+
33
+ def _fetch(tmp_path, trust):
34
+ fetcher = ReferenceFetcher(
35
+ ReferenceValidationConfig(
36
+ cache_dir=tmp_path, email="me@example.org", trust_cached_entries=trust
37
+ )
38
+ )
39
+ calls = []
40
+
41
+ class _Source:
42
+ def fetch(self, identifier, config):
43
+ calls.append(identifier)
44
+ return None
45
+
46
+ with patch(
47
+ "linkml_reference_validator.etl.reference_fetcher"
48
+ ".ReferenceSourceRegistry.get_source",
49
+ return_value=_Source,
50
+ ):
51
+ return fetcher.fetch("PMID:20301575"), calls
52
+
53
+
54
+ @pytest.mark.parametrize(
55
+ "content_type", ["full_text_html", "full_text_xml", "abstract_only"]
56
+ )
57
+ def test_a_trusted_entry_is_served_without_any_fetch(tmp_path, content_type):
58
+ """The point: no network call at all, so nothing can go wrong during one."""
59
+ _cache(tmp_path, content_type)
60
+ content, calls = _fetch(tmp_path, trust=True)
61
+
62
+ assert content is not None, f"{content_type} should be served from disk"
63
+ assert BODY in (content.content or "")
64
+ assert calls == [], f"a trusted entry must not be re-fetched, got {calls}"
65
+
66
+
67
+ def test_the_file_is_left_exactly_as_it_was(tmp_path):
68
+ """Nothing is rewritten, so a validation run leaves the cache untouched."""
69
+ _cache(tmp_path)
70
+ before = (tmp_path / "PMID_20301575.md").read_text(encoding="utf-8")
71
+ _fetch(tmp_path, trust=True)
72
+ assert (tmp_path / "PMID_20301575.md").read_text(encoding="utf-8") == before
73
+
74
+
75
+ def test_the_default_still_re_fetches(tmp_path):
76
+ """Off by default: an unstamped entry is still treated as stale."""
77
+ _cache(tmp_path)
78
+ _content, calls = _fetch(tmp_path, trust=False)
79
+ assert calls == ["20301575"], (
80
+ "by default an unstamped entry is re-fetched; the flag must be opt-in"
81
+ )