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()
+89 -4
View File
@@ -50,6 +50,19 @@ def test_zip_bomb_declared_size_over_cap_is_rejected(monkeypatch):
assert "error" in out and "too much data" in out["error"]
@pytest.mark.unit
@pytest.mark.parametrize(
"suffix", [".docm", ".xlsm", ".xlsb", ".pptm", ".ppsx", ".ppsm", ".odt", ".ods", ".odp"]
)
def test_zip_bomb_guard_covers_every_anydoc_zip_format(monkeypatch, suffix):
"""Chat attachments have no extension whitelist, so every zip-packaged format
the anydoc map routes must be gated the same way as .docx."""
monkeypatch.setattr(dr.settings, "DOCUMENT_MAX_DECOMPRESSED_BYTES", 1000, raising=False)
data = _make_zip({"content.xml": b"A" * 50_000})
reason = dr._reject_zip_bomb(data, suffix)
assert reason is not None and "too much data" in reason
@pytest.mark.unit
def test_zip_too_many_entries_is_rejected(monkeypatch):
monkeypatch.setattr(dr.settings, "DOCUMENT_MAX_ARCHIVE_ENTRIES", 3, raising=False)
@@ -186,7 +199,7 @@ class _FakeParser:
@pytest.mark.unit
def test_engine_picks_parser_and_coerces_list(monkeypatch):
fake = _FakeParser(["chunk A", "chunk B"])
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": fake})
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", output="text", engine="docling", include_tables=False)
assert out["content"] == "chunk A\n\nchunk B"
@@ -213,6 +226,9 @@ def test_default_markdown_with_tables_converts_docling_once(monkeypatch):
import sys
import types
# engine=auto resolves to the server default; this test is about the docling path.
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "docling")
from application.parser.file.docling_parser import DoclingParser
counter = {"instances": 0, "converts": 0}
@@ -272,7 +288,7 @@ def test_default_markdown_with_tables_converts_docling_once(monkeypatch):
# Only reached if the collapse path fails; the test asserts it doesn't.
return "# parse-file content"
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": _FakeDoclingParser()})
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": _FakeDoclingParser()})
# Defaults: output=markdown, engine=auto, include_tables=True -> the double-parse path.
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf")
@@ -293,7 +309,7 @@ def test_default_markdown_with_tables_converts_docling_once(monkeypatch):
@pytest.mark.unit
def test_pages_slices_form_feed_blob(monkeypatch):
fake = _FakeParser("page1\fpage2\fpage3")
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": fake})
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
out = parse_document_bytes(b"%PDF", "doc.pdf", output="text", pages="2", engine="docling", include_tables=False)
assert out["content"] == "page2"
@@ -443,7 +459,7 @@ def test_temp_file_cleaned_up_on_parser_error(monkeypatch):
fake = _FakeParser("x")
fake.parse_file = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None: {".pdf": fake})
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
out = parse_document_bytes(b"%PDF", "doc.pdf", output="text", engine="docling", include_tables=False)
assert "error" in out and "parsing failed" in out["error"]
@@ -622,3 +638,72 @@ def test_structured_tables_render_blank_cells_as_strings(monkeypatch, tmp_path):
assert all(isinstance(c, str) for c in cells), f"non-string cells: {cells!r}"
assert "1001" in cells and "1001.0" not in cells
assert "" in cells # blank cell renders as empty string, not "nan"/"None"
# ---------------------------------------------------------------------------
# engine=anydoc / auto: the default engine must never trigger a docling pass
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_anydoc_engine_is_accepted_and_passed_through(monkeypatch):
fake = _FakeParser("anydoc text")
seen = {}
def _extractor(ocr_enabled=None, **kwargs):
seen.update(kwargs)
return {".pdf": fake}
monkeypatch.setattr(dr, "get_default_file_extractor", _extractor)
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", output="text", engine="anydoc", include_tables=False)
assert out["content"] == "anydoc text"
assert seen == {"engine": "anydoc"}
@pytest.mark.unit
def test_auto_engine_under_anydoc_skips_docling_table_pass(monkeypatch):
"""Default read (markdown, engine=auto, include_tables) must not run a docling conversion
just to harvest tables when the configured engine is anydoc — that pass would cost more
than the whole read and re-import the stack anydoc exists to avoid."""
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "anydoc")
fake = _FakeParser("# md body")
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
def _boom(*args, **kwargs):
raise AssertionError("docling conversion attempted under the anydoc engine")
monkeypatch.setattr(dr, "_docling_structured", _boom)
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf") # markdown, auto, include_tables=True
assert out["content"] == "# md body"
assert "tables" not in out
@pytest.mark.unit
def test_explicit_docling_engine_still_collects_tables(monkeypatch):
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "anydoc")
fake = _FakeParser("body")
monkeypatch.setattr(dr, "get_default_file_extractor", lambda ocr_enabled=None, **kwargs: {".pdf": fake})
monkeypatch.setattr(
dr,
"_docling_structured",
lambda *a, **k: {"markdown": "body", "tables": [{"markdown": "| t |"}], "structured": {}, "page_count": 1},
)
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", engine="docling")
assert out["tables"] == [{"markdown": "| t |"}]
@pytest.mark.unit
def test_structured_output_stays_docling_under_anydoc(monkeypatch):
monkeypatch.setattr(dr.settings, "DOC_PARSER_ENGINE", "anydoc")
calls = []
def _fake_structured(path, *, ocr_enabled, include_tables, parser=None):
calls.append(path)
return {"markdown": "# s", "tables": [], "structured": {"texts": []}, "page_count": 1}
monkeypatch.setattr(dr, "_docling_structured", _fake_structured)
out = parse_document_bytes(b"%PDF-1.4", "doc.pdf", output="structured")
assert out["output"] == "structured"
assert len(calls) == 1