Implement anydoc with docling

This commit is contained in:
Pavel committed 2026-08-27 13:51:09 +04:00
1 parent e0aff39a1b
commit e101c0a7f1
18 files changed
+1443 -153

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
"""Tests for the anydoc parser (the default ``DOC_PARSER_ENGINE``).
Three behaviours matter and are asserted separately:
* a file anydoc converts comes back as Markdown, with ``last_engine`` set;
* a file it refuses (a scanned PDF, an unknown format) goes to the fallback
parser when one is configured, and every failure surfaces as
``DocumentParseError`` — never as an empty document or a raw traceback;
* the suffix list the parser map is built from matches what the installed
anydoc actually accepts.
"""
import importlib.machinery
import sys
import types
import pytest
from application.parser.file.base_parser import BaseParser, DocumentParseError
anydoc = pytest.importorskip("anydoc")
from application.parser.file.anydoc_parser import ( # noqa: E402 — after importorskip
ANYDOC_SUFFIXES,
AnydocParser,
anydoc_available,
)
class _RecordingFallback(BaseParser):
"""Stands in for docling / a legacy parser so delegation is observable."""
def __init__(self, result="fallback text"):
super().__init__(parser_config={})
self.calls = []
self._result = result
def _init_parser(self):
return {}
def parse_file(self, file, errors="ignore"):
self.calls.append(file)
return self._result
class _ExplodingFallback(BaseParser):
def _init_parser(self):
return {}
def parse_file(self, file, errors="ignore"):
raise RuntimeError("fallback blew up")
def _scanned_pdf(path, pages=2):
"""A PDF with pages but no text layer: what a scan looks like to a parser."""
from pypdf import PdfWriter
writer = PdfWriter()
for _ in range(pages):
writer.add_blank_page(width=612, height=792)
with open(path, "wb") as fh:
writer.write(fh)
return path
def _fake_anydoc(monkeypatch, to_markdown):
"""Install a stub ``anydoc`` module whose ``to_markdown`` is ``to_markdown``."""
fake = types.ModuleType("anydoc")
fake.__spec__ = importlib.machinery.ModuleSpec("anydoc", None)
class ConvertError(Exception):
pass
class UnsupportedError(ConvertError):
pass
class ResourceLimitError(ConvertError):
pass
fake.ConvertError = ConvertError
fake.UnsupportedError = UnsupportedError
fake.ResourceLimitError = ResourceLimitError
fake.to_markdown = to_markdown
monkeypatch.setitem(sys.modules, "anydoc", fake)
return fake
# --- the suffix list is what anydoc really supports ---------------------------
def test_every_listed_suffix_is_an_anydoc_format():
for suffix in ANYDOC_SUFFIXES:
assert anydoc.format_from_extension(suffix) is not None, suffix
def test_epub_and_html_are_not_routed_to_anydoc():
assert ".epub" not in ANYDOC_SUFFIXES
assert ".html" not in ANYDOC_SUFFIXES
# --- successful conversion -----------------------------------------------------
def test_csv_converts_to_markdown_table(tmp_path):
path = tmp_path / "items.csv"
path.write_text("name,qty\nwidget,3\ngadget,5\n")
parser = AnydocParser()
parser.init_parser()
out = parser.parse_file(path)
assert isinstance(out, str)
assert "widget" in out and "gadget" in out
assert "|" in out # rendered as a GFM table, not flat text
assert parser.last_engine == "anydoc"
def test_xlsx_converts_with_cell_text(tmp_path):
openpyxl = pytest.importorskip("openpyxl")
path = tmp_path / "book.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["city", "population"])
ws.append(["Ljubljana", 295000])
wb.save(path)
out = AnydocParser().parse_file(path)
assert "Ljubljana" in out
assert "295000" in out
def test_accepts_path_and_str(tmp_path):
path = tmp_path / "a.csv"
path.write_text("x,y\n1,2\n")
assert AnydocParser().parse_file(str(path)) == AnydocParser().parse_file(path)
# --- refusal routes to the fallback --------------------------------------------
def test_scanned_pdf_delegates_to_fallback(tmp_path):
path = _scanned_pdf(tmp_path / "scan.pdf")
fallback = _RecordingFallback()
parser = AnydocParser(fallback_parser=fallback)
out = parser.parse_file(path)
assert out == "fallback text"
assert fallback.calls == [path]
assert parser.last_engine == "_RecordingFallback"
def test_fallback_last_engine_is_reported_when_it_has_one(tmp_path):
path = _scanned_pdf(tmp_path / "scan.pdf")
fallback = _RecordingFallback()
fallback.last_engine = "pypdfium2"
parser = AnydocParser(fallback_parser=fallback)
parser.parse_file(path)
assert parser.last_engine == "pypdfium2"
def test_scanned_pdf_without_fallback_is_document_parse_error(tmp_path):
path = _scanned_pdf(tmp_path / "scan.pdf")
parser = AnydocParser()
with pytest.raises(DocumentParseError, match="scan.pdf"):
parser.parse_file(path)
assert parser.last_engine is None
def test_unknown_format_without_fallback_is_document_parse_error(tmp_path):
path = tmp_path / "blob.xyz"
path.write_bytes(b"hello")
with pytest.raises(DocumentParseError, match="blob.xyz"):
AnydocParser().parse_file(path)
def test_fallback_failure_is_document_parse_error(tmp_path):
"""A fallback that raises must not leak a bare exception (load_data skips only DocumentParseError)."""
path = _scanned_pdf(tmp_path / "scan.pdf")
with pytest.raises(DocumentParseError):
AnydocParser(fallback_parser=_ExplodingFallback()).parse_file(path)
def test_fallback_document_parse_error_passes_through(tmp_path):
class _Refusing(BaseParser):
def _init_parser(self):
return {}
def parse_file(self, file, errors="ignore"):
raise DocumentParseError("scan needs OCR")
path = _scanned_pdf(tmp_path / "scan.pdf")
with pytest.raises(DocumentParseError, match="scan needs OCR"):
AnydocParser(fallback_parser=_Refusing()).parse_file(path)
def test_missing_file_is_document_parse_error(tmp_path):
with pytest.raises(DocumentParseError, match="could not be read"):
AnydocParser().parse_file(tmp_path / "missing.docx")
def test_empty_output_delegates(tmp_path, monkeypatch):
"""Whitespace-only output is treated as a failed conversion, not stored."""
_fake_anydoc(monkeypatch, lambda path: " \n\n ")
fallback = _RecordingFallback("real text")
path = tmp_path / "x.docx"
path.write_bytes(b"irrelevant")
out = AnydocParser(fallback_parser=fallback).parse_file(path)
assert out == "real text"
assert fallback.calls == [path]
def test_typed_convert_error_delegates(tmp_path, monkeypatch):
fake = _fake_anydoc(monkeypatch, None)
def _refuse(path):
raise fake.UnsupportedError("PDF has no extractable text; OCR is required")
fake.to_markdown = _refuse
fallback = _RecordingFallback()
path = tmp_path / "x.pdf"
path.write_bytes(b"%PDF-1.4")
assert AnydocParser(fallback_parser=fallback).parse_file(path) == "fallback text"
def test_typed_convert_error_message_reaches_the_user(tmp_path, monkeypatch):
fake = _fake_anydoc(monkeypatch, None)
def _refuse(path):
raise fake.UnsupportedError("PDF has no extractable text; OCR is required")
fake.to_markdown = _refuse
path = tmp_path / "x.pdf"
path.write_bytes(b"%PDF-1.4")
with pytest.raises(DocumentParseError, match="OCR is required"):
AnydocParser().parse_file(path)
def test_resource_limit_is_terminal_even_with_fallback(tmp_path, monkeypatch):
"""anydoc refusing a file as too expensive must not hand it to a heavier
engine that would then spend exactly what was refused."""
fake = _fake_anydoc(monkeypatch, None)
def _refuse(path):
raise fake.ResourceLimitError(
"resource limit exceeded (max_entry_bytes): word/document.xml declares 400400167 bytes"
)
fake.to_markdown = _refuse
fallback = _RecordingFallback()
parser = AnydocParser(fallback_parser=fallback)
path = tmp_path / "bomb.docx"
path.write_bytes(b"PK")
with pytest.raises(DocumentParseError, match="max_entry_bytes"):
parser.parse_file(path)
assert fallback.calls == []
assert parser.last_engine is None
def test_unexpected_exception_is_document_parse_error(tmp_path, monkeypatch):
def _crash(path):
raise RuntimeError("panic")
_fake_anydoc(monkeypatch, _crash)
path = tmp_path / "x.docx"
path.write_bytes(b"irrelevant")
with pytest.raises(DocumentParseError, match="x.docx"):
AnydocParser().parse_file(path)
# --- init / availability --------------------------------------------------------
def test_init_parser_records_fallback():
parser = AnydocParser(fallback_parser=_RecordingFallback())
parser.init_parser()
assert parser.parser_config == {"fallback_parser": "_RecordingFallback"}
bare = AnydocParser()
bare.init_parser()
assert bare.parser_config == {"fallback_parser": None}
def test_init_parser_without_anydoc_raises_import_error(monkeypatch):
monkeypatch.setitem(sys.modules, "anydoc", None)
assert not anydoc_available()
with pytest.raises(ImportError, match="firecrawl-anydoc"):
AnydocParser().init_parser()
def test_init_parser_imports_for_real_not_just_find_spec(monkeypatch):
"""A wheel whose native extension fails to load has a spec but no importable
module; that must surface at init, not as a bare ImportError from parse_file
mid-ingest (which load_data does not catch)."""
import application.parser.file.anydoc_parser as mod
monkeypatch.setattr(mod, "anydoc_available", lambda: True)
monkeypatch.setitem(sys.modules, "anydoc", None)
with pytest.raises(ImportError, match="firecrawl-anydoc"):
AnydocParser().init_parser()
+120
View File
@@ -508,3 +508,123 @@ class TestGetDefaultFileExtractor:
mock_fn.return_value = {".pdf": MagicMock(), ".md": MagicMock()}
result = mock_fn()
assert ".pdf" in result
# =====================================================================
# DOC_PARSER_ENGINE switch
# =====================================================================
@pytest.mark.unit
class TestParserEngineSwitch:
"""``get_default_file_extractor`` honours ``DOC_PARSER_ENGINE`` / ``engine``."""
@pytest.fixture
def settings(self):
from application.core.settings import settings
return settings
def test_default_engine_is_anydoc(self, settings, monkeypatch):
pytest.importorskip("anydoc")
from application.parser.file.anydoc_parser import ANYDOC_SUFFIXES, AnydocParser
from application.parser.file.bulk import get_default_file_extractor
from application.parser.file.html_parser import HTMLMarkdownParser
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "anydoc")
extractor = get_default_file_extractor()
for suffix in ANYDOC_SUFFIXES:
assert isinstance(extractor[suffix], AnydocParser), suffix
assert isinstance(extractor[".html"], HTMLMarkdownParser)
assert isinstance(extractor[".xhtml"], HTMLMarkdownParser)
# Specialised parsers are untouched by the engine choice.
assert type(extractor[".md"]).__name__ == "MarkdownParser"
assert type(extractor[".json"]).__name__ == "JSONParser"
assert type(extractor[".epub"]).__name__ == "EpubParser"
def test_anydoc_falls_back_to_docling_when_installed(self):
pytest.importorskip("anydoc")
pytest.importorskip("docling")
from application.parser.file.bulk import get_default_file_extractor
extractor = get_default_file_extractor(engine="anydoc")
assert type(extractor[".pdf"].fallback_parser).__name__ == "DoclingPDFParser"
assert type(extractor[".csv"].fallback_parser).__name__ == "DoclingCSVParser"
assert extractor[".doc"].fallback_parser is None # nothing else reads .doc
# docling keeps the formats anydoc cannot read.
assert type(extractor[".vtt"]).__name__ == "DoclingVTTParser"
def test_anydoc_fallback_honours_ocr_flag(self):
pytest.importorskip("anydoc")
pytest.importorskip("docling")
from application.parser.file.bulk import get_default_file_extractor
extractor = get_default_file_extractor(engine="anydoc", ocr_enabled=True)
assert extractor[".pdf"].fallback_parser.ocr_enabled is True
assert type(extractor[".png"]).__name__ == "DoclingImageParser"
def test_anydoc_falls_back_to_legacy_without_docling(self, monkeypatch):
pytest.importorskip("anydoc")
import sys
from application.parser.file.bulk import get_default_file_extractor
monkeypatch.setitem(sys.modules, "docling", None)
extractor = get_default_file_extractor(engine="anydoc")
assert type(extractor[".pdf"].fallback_parser).__name__ == "PDFParser"
assert type(extractor[".xlsx"].fallback_parser).__name__ == "ExcelParser"
assert type(extractor[".png"]).__name__ == "ImageParser"
def test_docling_engine_keeps_docling_map(self):
pytest.importorskip("docling")
from application.parser.file.bulk import get_default_file_extractor
extractor = get_default_file_extractor(engine="docling")
assert type(extractor[".pdf"]).__name__ == "DoclingPDFParser"
assert type(extractor[".html"]).__name__ == "DoclingHTMLParser"
def test_setting_selects_docling(self, settings, monkeypatch):
pytest.importorskip("docling")
from application.parser.file.bulk import get_default_file_extractor
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "docling")
assert type(get_default_file_extractor()[".pdf"]).__name__ == "DoclingPDFParser"
def test_unknown_engine_uses_anydoc(self):
pytest.importorskip("anydoc")
from application.parser.file.anydoc_parser import AnydocParser
from application.parser.file.bulk import get_default_file_extractor
assert isinstance(get_default_file_extractor(engine="ghost")[".pdf"], AnydocParser)
def test_missing_anydoc_degrades_to_base_engine(self, monkeypatch):
import sys
from application.parser.file.bulk import get_default_file_extractor
monkeypatch.setitem(sys.modules, "anydoc", None)
extractor = get_default_file_extractor(engine="anydoc")
assert type(extractor[".pdf"]).__name__ in ("DoclingPDFParser", "PDFParser")
assert type(extractor[".html"]).__name__ in ("DoclingHTMLParser", "HTMLParser")
def test_missing_docling_map_is_usable(self, monkeypatch):
"""Regression: with docling absent the map handed out docling parsers whose
``init_parser`` raised ``ImportError`` — not a ``DocumentParseError`` — so
the first file aborted the whole ingest instead of degrading."""
import sys
from application.parser.file.bulk import get_default_file_extractor
monkeypatch.setitem(sys.modules, "docling", None)
extractor = get_default_file_extractor(engine="docling")
assert type(extractor[".pdf"]).__name__ == "PDFParser"
assert ".xhtml" in extractor
for suffix in (".pdf", ".docx", ".csv", ".xlsx", ".html", ".xhtml", ".pptx"):
extractor[suffix].init_parser()
+177
View File
@@ -54,3 +54,180 @@ def test_html_parser_metadata_without_title(tmp_path):
def test_html_parser_metadata_unreadable_file(tmp_path):
assert HTMLParser().get_file_metadata(tmp_path / "missing.html") == {}
# --- HTMLMarkdownParser: the anydoc engine's HTML path ---------------------------
from application.parser.file.html_parser import HTMLMarkdownParser, html_to_markdown # noqa: E402
RICH_HTML = """<html><head><title>Doc Title</title>
<style>.x { color: red }</style><script>var secret = 1;</script></head>
<body>
<h1>Heading</h1>
<p>See <a href="../ref.html#anchor">the ref</a> for details.</p>
<table><tr><th>h1</th><th>h2</th></tr><tr><td>1</td><td>2</td></tr></table>
<pre><code>typing.__name__, typing.__spec__.name</code></pre>
</body></html>"""
@pytest.fixture
def rich_html_file(tmp_path):
path = tmp_path / "rich.html"
path.write_text(RICH_HTML)
return path
def test_markdown_parser_keeps_structure(rich_html_file):
out = HTMLMarkdownParser().parse_file(rich_html_file)
assert isinstance(out, str)
assert "# Heading" in out
assert "[the ref](../ref.html#anchor)" in out # links kept, hrefs verbatim
assert "| h1 | h2 |" in out and "| 1 | 2 |" in out # GFM table
assert "typing.__name__, typing.__spec__.name" in out # code byte-exact
def test_markdown_parser_drops_non_content(rich_html_file):
out = HTMLMarkdownParser().parse_file(rich_html_file)
assert "var secret" not in out
assert "color: red" not in out
assert "Doc Title" not in out # title is metadata, not body text
def test_markdown_parser_reports_title_metadata(rich_html_file):
assert HTMLMarkdownParser().get_file_metadata(rich_html_file) == {"title": "Doc Title"}
def test_markdown_parser_differs_from_plain_text_contract(rich_html_file):
"""``HTMLParser`` stays plain text (the ``fast`` engine's contract); only the new class emits Markdown."""
plain = HTMLParser().parse_file(rich_html_file)
assert "# Heading" not in plain
assert "[the ref]" not in plain
def test_markdown_parser_handles_xhtml(tmp_path):
path = tmp_path / "page.xhtml"
path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" '
'"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n'
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>X</title></head>'
"<body><h2>Section</h2><p>Body &amp; entities.</p></body></html>"
)
out = HTMLMarkdownParser().parse_file(path)
assert "## Section" in out
assert "Body & entities." in out
def test_markdown_parser_init():
parser = HTMLMarkdownParser()
assert not parser.parser_config_set
parser.init_parser()
assert parser.parser_config_set
def test_html_to_markdown_collapses_blank_runs():
out = html_to_markdown("<p>a</p><br/><br/><br/><br/><p>b</p>")
assert "\n\n\n" not in out
assert out.startswith("a") and out.endswith("b")
# --- HTMLMarkdownParser: input bounding and decoding ------------------------------
def test_markdown_parser_head_truncates_oversized_markup(tmp_path, monkeypatch):
"""Markup past MARKUP_MAX_BYTES is not parsed: the soup+markdownify tree costs
~50x the input, and the upload cap is 100 MB."""
from application.core.settings import settings
monkeypatch.setattr(settings, "MARKUP_MAX_BYTES", 600)
path = tmp_path / "big.html"
body = "".join(f"<p>para {i}</p>\n" for i in range(300))
path.write_text("<html><head><title>Big</title></head><body>\n" + body + "</body></html>")
assert path.stat().st_size > 600
out = HTMLMarkdownParser().parse_file(path)
assert "para 0" in out
assert "para 299" not in out
assert HTMLMarkdownParser().get_file_metadata(path) == {"title": "Big"}
def test_markdown_parser_gate_disabled_reads_everything(tmp_path, monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "MARKUP_MAX_BYTES", 0)
path = tmp_path / "big.html"
body = "".join(f"<p>para {i}</p>\n" for i in range(300))
path.write_text("<html><body>\n" + body + "</body></html>")
out = HTMLMarkdownParser().parse_file(path)
assert "para 299" in out
def test_markdown_parser_honours_declared_charset(tmp_path):
"""The file is handed to BeautifulSoup as bytes so ``<meta charset>`` wins
over the process locale (docling did the same; the text parser does not)."""
path = tmp_path / "cp1250.html"
path.write_bytes(
'<html><head><meta charset="windows-1250"><title>Plzeň</title></head>'
"<body><p>Jiří z Plzně</p></body></html>".encode("cp1250")
)
assert "Jiří z Plzně" in HTMLMarkdownParser().parse_file(path)
assert HTMLMarkdownParser().get_file_metadata(path) == {"title": "Plzeň"}
def test_markdown_parser_metadata_unreadable_file(tmp_path):
assert HTMLMarkdownParser().get_file_metadata(tmp_path / "missing.html") == {}
def test_markdown_parser_cut_inside_utf8_char_does_not_mojibake_the_page(tmp_path, monkeypatch):
"""A byte cut mid-character makes strict UTF-8 fail; BeautifulSoup would then
retry as windows-1252, which *succeeds* on umlauts and garbles everything."""
from application.core.settings import settings
path = tmp_path / "minified.html"
body = "<p>Größe Übermaß schön für</p>" * 400 # no newlines: the cut is arbitrary
path.write_bytes(
('<html><head><meta charset="utf-8"><title>Größe</title></head><body>' + body + "</body></html>")
.encode("utf-8")
)
for cap in (2001, 2002, 2003): # one of these lands inside a 2-byte sequence
monkeypatch.setattr(settings, "MARKUP_MAX_BYTES", cap)
out = HTMLMarkdownParser().parse_file(path)
assert "Größe Übermaß schön für" in out, cap
assert "Ã" not in out, cap
def test_trim_torn_utf8_tail():
from application.parser.file.html_parser import _trim_torn_utf8_tail as trim
e_acute, snowman, emoji = "é".encode(), "☃".encode(), "😀".encode()
assert trim(b"abc") == b"abc"
assert trim(b"abc" + e_acute) == b"abc" + e_acute # complete 2-byte
assert trim(b"abc" + e_acute[:1]) == b"abc" # torn 2-byte
assert trim(b"abc" + snowman[:2]) == b"abc" # torn 3-byte
assert trim(b"abc" + emoji[:3]) == b"abc" # torn 4-byte
assert trim(b"abc" + emoji) == b"abc" + emoji
assert trim(b"") == b""
assert trim(b"\xe8") == b"" # lone lead byte (also a cp1250 letter: at most one glyph lost)
def test_markdown_parser_metadata_reuses_last_parse(tmp_path, rich_html_file, monkeypatch):
"""The metadata call after parse_file must not build the soup again."""
import application.parser.file.html_parser as mod
parser = HTMLMarkdownParser()
parser.parse_file(rich_html_file)
calls = []
monkeypatch.setattr(mod, "read_markup_head", lambda *a, **k: calls.append(a) or b"")
assert parser.get_file_metadata(rich_html_file) == {"title": "Doc Title"}
assert calls == []
# A different file is read afresh.
other = tmp_path / "other.html"
other.write_text("<title>Other</title>")
parser.get_file_metadata(other)
assert len(calls) == 1
+24 -3
View File
@@ -153,9 +153,17 @@ def test_threshold_sends_sparse_text_to_fallback(tmp_path, parser_cls):
# --- extractor wiring: the guard that source ingestion keeps docling ---------
# (under the docling engine; the anydoc engine ignores the fast path, see below)
def test_extractor_defaults_to_docling_for_pdf():
@pytest.fixture
def docling_engine(monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "docling")
def test_extractor_defaults_to_docling_for_pdf(docling_engine):
"""Sources must be unaffected: no fast path unless explicitly requested."""
from application.parser.file.bulk import get_default_file_extractor
@@ -164,7 +172,7 @@ def test_extractor_defaults_to_docling_for_pdf():
assert type(pdf_parser).__name__ == "DoclingPDFParser"
def test_extractor_uses_fast_path_when_requested():
def test_extractor_uses_fast_path_when_requested(docling_engine):
from application.parser.file.bulk import get_default_file_extractor
from application.parser.file.pdfium_parser import PdfiumTextParser
@@ -174,7 +182,20 @@ def test_extractor_uses_fast_path_when_requested():
assert type(pdf_parser.fallback_parser).__name__ == "DoclingPDFParser"
def test_fast_path_does_not_change_non_pdf_parsers():
def test_anydoc_engine_ignores_fast_path(monkeypatch):
"""anydoc already reads the text layer in milliseconds and keeps structure."""
pytest.importorskip("anydoc")
from application.core.settings import settings
from application.parser.file.bulk import get_default_file_extractor
monkeypatch.setattr(settings, "DOC_PARSER_ENGINE", "anydoc")
pdf_parser = get_default_file_extractor(pdf_text_fast_path=True)[".pdf"]
assert type(pdf_parser).__name__ == "AnydocParser"
def test_fast_path_does_not_change_non_pdf_parsers(docling_engine):
from application.parser.file.bulk import get_default_file_extractor
plain = get_default_file_extractor()