mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 12:16:20 +00:00
Implement anydoc with docling
This commit is contained in:
1 parent
e0aff39a1b
commit
e101c0a7f1
18 files changed
+1443
-153
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 & 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user