363 lines
12 KiB
Python
363 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Parser Validation Test Script for RAG-Anything (Pytest)
|
|
|
|
This script validates the environment variable propagation and
|
|
argument validation logic for both MineruParser and DoclingParser.
|
|
|
|
For MineruParser, env={...} is still propagated to the subprocess and is
|
|
asserted as such. For DoclingParser the implementation now uses the Docling
|
|
Python API rather than the `docling` CLI; the legacy env kwarg is therefore
|
|
accepted for backward compatibility but ignored, and the tests below
|
|
exercise the Python-API path through DocumentConverter mocks instead of
|
|
subprocess mocks.
|
|
|
|
Requirements:
|
|
- RAG-Anything package
|
|
- pytest
|
|
|
|
Usage:
|
|
pytest tests/testparser_kwargs.py
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
import os
|
|
import re
|
|
from raganything.parser import MineruParser, DoclingParser, Parser
|
|
import raganything.parser as parser_module
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.fixture
|
|
def mineru_parser():
|
|
return MineruParser()
|
|
|
|
|
|
@pytest.fixture
|
|
def docling_parser():
|
|
return DoclingParser()
|
|
|
|
|
|
@pytest.fixture
|
|
def dummy_path():
|
|
return "dummy.pdf"
|
|
|
|
|
|
def _mock_docling_converter() -> MagicMock:
|
|
"""Build a DocumentConverter mock with the minimum API surface used by
|
|
`DoclingParser._run_docling_python`."""
|
|
fake_doc = MagicMock()
|
|
fake_doc.export_to_dict.return_value = {"body": {}}
|
|
fake_doc.export_to_markdown.return_value = ""
|
|
converter = MagicMock()
|
|
converter.convert.return_value = MagicMock(document=fake_doc)
|
|
return converter
|
|
|
|
|
|
@patch("subprocess.Popen")
|
|
@patch("pathlib.Path.exists")
|
|
@patch("pathlib.Path.mkdir")
|
|
def test_mineru_env_propagation(
|
|
mock_mkdir, mock_exists, mock_popen, mineru_parser, dummy_path
|
|
):
|
|
mock_exists.return_value = True
|
|
mock_process = MagicMock()
|
|
mock_process.poll.return_value = 0
|
|
mock_process.wait.return_value = 0
|
|
mock_process.stdout.readline.return_value = ""
|
|
mock_process.stderr.readline.return_value = ""
|
|
mock_popen.return_value = mock_process
|
|
|
|
custom_env = {"MY_VAR": "test_value"}
|
|
|
|
# Test env propagation
|
|
try:
|
|
mineru_parser._run_mineru_command(dummy_path, "out", env=custom_env)
|
|
except Exception:
|
|
pass
|
|
|
|
args, kwargs = mock_popen.call_args
|
|
assert "env" in kwargs
|
|
assert kwargs["env"]["MY_VAR"] == "test_value"
|
|
assert kwargs["env"]["PATH"] == os.environ["PATH"]
|
|
|
|
|
|
@patch.object(DoclingParser, "_get_converter")
|
|
def test_docling_env_accepted_but_ignored(
|
|
mock_get_converter, docling_parser, dummy_path, tmp_path
|
|
):
|
|
"""Docling now ignores `env={...}`: the call must succeed without raising
|
|
and the underlying DocumentConverter must still be invoked."""
|
|
mock_get_converter.return_value = _mock_docling_converter()
|
|
|
|
custom_env = {"DOCLING_VAR": "docling_value"}
|
|
docling_parser._run_docling_python(
|
|
input_path=dummy_path,
|
|
output_dir=tmp_path,
|
|
file_stem="stem",
|
|
env=custom_env,
|
|
)
|
|
|
|
# The Python-API path was used (no subprocess), env was silently dropped.
|
|
mock_get_converter.assert_called_once()
|
|
converter = mock_get_converter.return_value
|
|
converter.convert.assert_called_once_with(str(dummy_path))
|
|
|
|
|
|
def test_mineru_unknown_kwargs(mineru_parser, dummy_path):
|
|
# Mineru should fail fast on unknown kwargs
|
|
with pytest.raises(TypeError) as excinfo:
|
|
mineru_parser._run_mineru_command(dummy_path, "out", unknown_arg="fail")
|
|
assert "unexpected keyword argument(s): unknown_arg" in str(excinfo.value)
|
|
|
|
|
|
@patch.object(DoclingParser, "_get_converter")
|
|
def test_docling_unknown_kwargs(
|
|
mock_get_converter, docling_parser, dummy_path, tmp_path
|
|
):
|
|
"""Docling should accept unknown kwargs without raising — they are
|
|
forwarded to `_get_converter` and silently ignored if unrecognized."""
|
|
mock_get_converter.return_value = _mock_docling_converter()
|
|
|
|
docling_parser._run_docling_python(
|
|
input_path=dummy_path,
|
|
output_dir=tmp_path,
|
|
file_stem="stem",
|
|
unknown_arg="allow",
|
|
)
|
|
mock_get_converter.assert_called_once()
|
|
|
|
|
|
def test_invalid_env_type(mineru_parser, docling_parser, dummy_path, tmp_path):
|
|
# Test non-dict env
|
|
with pytest.raises(TypeError, match="env must be a dictionary"):
|
|
mineru_parser._run_mineru_command(dummy_path, "out", env=["not", "a", "dict"])
|
|
|
|
# Validation happens before any converter call, so no mocking needed.
|
|
with pytest.raises(TypeError, match="env must be a dictionary"):
|
|
docling_parser._run_docling_python(
|
|
input_path=dummy_path,
|
|
output_dir=tmp_path,
|
|
file_stem="stem",
|
|
env="string",
|
|
)
|
|
|
|
|
|
def test_invalid_env_contents(mineru_parser, docling_parser, dummy_path, tmp_path):
|
|
# Test non-string keys/values
|
|
with pytest.raises(TypeError, match="env keys and values must be strings"):
|
|
mineru_parser._run_mineru_command(dummy_path, "out", env={1: "string_val"})
|
|
|
|
with pytest.raises(TypeError, match="env keys and values must be strings"):
|
|
docling_parser._run_docling_python(
|
|
input_path=dummy_path,
|
|
output_dir=tmp_path,
|
|
file_stem="stem",
|
|
env={"key": 123},
|
|
)
|
|
|
|
|
|
def test_mineru_windows_unsafe_pdf_uses_safe_paths(
|
|
monkeypatch, mineru_parser, tmp_path
|
|
):
|
|
monkeypatch.setattr(parser_module, "_IS_WINDOWS", True)
|
|
source_dir = tmp_path / "docs"
|
|
source_dir.mkdir()
|
|
pdf_path = source_dir / "sample-测试 .pdf"
|
|
pdf_path.write_bytes(b"%PDF-1.4\n")
|
|
|
|
captured = {}
|
|
|
|
def fake_run_mineru_command(**kwargs):
|
|
captured.update(kwargs)
|
|
assert kwargs["input_path"].exists()
|
|
assert kwargs["input_path"].read_bytes() == b"%PDF-1.4\n"
|
|
|
|
with (
|
|
patch.object(
|
|
MineruParser, "_run_mineru_command", side_effect=fake_run_mineru_command
|
|
),
|
|
patch.object(MineruParser, "_read_output_files", return_value=([], "")) as read,
|
|
):
|
|
mineru_parser.parse_pdf(pdf_path, output_dir=tmp_path / "out")
|
|
|
|
input_path = captured["input_path"]
|
|
output_dir = captured["output_dir"]
|
|
assert re.fullmatch(r"input_[0-9a-f]{10}\.pdf", input_path.name)
|
|
assert re.fullmatch(r"mineru_[0-9a-f]{10}", output_dir.name)
|
|
assert input_path.name.isascii()
|
|
assert output_dir.name.isascii()
|
|
assert read.call_args.args[1] == input_path.stem
|
|
|
|
|
|
def test_mineru_windows_safe_pdf_keeps_original_path(
|
|
monkeypatch, mineru_parser, tmp_path
|
|
):
|
|
monkeypatch.setattr(parser_module, "_IS_WINDOWS", True)
|
|
pdf_path = tmp_path / "sample.pdf"
|
|
pdf_path.write_bytes(b"%PDF-1.4\n")
|
|
output_dir = tmp_path / "out"
|
|
|
|
captured = {}
|
|
|
|
def fake_run_mineru_command(**kwargs):
|
|
captured.update(kwargs)
|
|
|
|
with (
|
|
patch.object(
|
|
MineruParser, "_run_mineru_command", side_effect=fake_run_mineru_command
|
|
),
|
|
patch.object(MineruParser, "_read_output_files", return_value=([], "")) as read,
|
|
):
|
|
mineru_parser.parse_pdf(pdf_path, output_dir=output_dir)
|
|
|
|
assert captured["input_path"] == pdf_path
|
|
assert captured["output_dir"] == MineruParser._unique_output_dir(
|
|
output_dir, pdf_path
|
|
)
|
|
assert read.call_args.args[1] == "sample"
|
|
|
|
|
|
def test_mineru_windows_unsafe_png_uses_safe_paths(
|
|
monkeypatch, mineru_parser, tmp_path
|
|
):
|
|
monkeypatch.setattr(parser_module, "_IS_WINDOWS", True)
|
|
source_dir = tmp_path / "docs"
|
|
source_dir.mkdir()
|
|
image_path = source_dir / "sample-测试 .png"
|
|
image_path.write_bytes(b"\x89PNG\r\n")
|
|
|
|
captured = {}
|
|
|
|
def fake_run_mineru_command(**kwargs):
|
|
captured.update(kwargs)
|
|
assert kwargs["method"] == "ocr"
|
|
assert kwargs["input_path"].exists()
|
|
assert kwargs["input_path"].read_bytes() == b"\x89PNG\r\n"
|
|
|
|
with (
|
|
patch.object(
|
|
MineruParser, "_run_mineru_command", side_effect=fake_run_mineru_command
|
|
),
|
|
patch.object(MineruParser, "_read_output_files", return_value=([], "")) as read,
|
|
):
|
|
mineru_parser.parse_image(image_path, output_dir=tmp_path / "out")
|
|
|
|
input_path = captured["input_path"]
|
|
output_dir = captured["output_dir"]
|
|
assert re.fullmatch(r"input_[0-9a-f]{10}\.png", input_path.name)
|
|
assert re.fullmatch(r"mineru_[0-9a-f]{10}", output_dir.name)
|
|
assert input_path.name.isascii()
|
|
assert output_dir.name.isascii()
|
|
assert read.call_args.args[1] == input_path.stem
|
|
|
|
|
|
@patch.object(DoclingParser, "_get_converter")
|
|
def test_docling_converter_cache_reused(
|
|
mock_get_converter, docling_parser, dummy_path, tmp_path
|
|
):
|
|
"""Two parses with the same kwargs must reuse the cached converter."""
|
|
mock_get_converter.return_value = _mock_docling_converter()
|
|
|
|
docling_parser._run_docling_python(
|
|
input_path=dummy_path,
|
|
output_dir=tmp_path,
|
|
file_stem="stem1",
|
|
)
|
|
docling_parser._run_docling_python(
|
|
input_path=dummy_path,
|
|
output_dir=tmp_path,
|
|
file_stem="stem2",
|
|
)
|
|
|
|
# _get_converter was called twice (once per parse), but a real, unmocked
|
|
# implementation would build the underlying DocumentConverter only once
|
|
# thanks to `_converter_cache`. The cache itself is exercised in
|
|
# `test_docling_converter_cache_unit` below.
|
|
assert mock_get_converter.call_count == 2
|
|
|
|
|
|
def test_docling_converter_cache_unit(docling_parser):
|
|
"""Direct unit test for the cache: same kwargs return the same converter
|
|
instance, different kwargs build a new one."""
|
|
sentinel_a = object()
|
|
sentinel_b = object()
|
|
|
|
def fake_build(**kwargs):
|
|
# Mimic the real `_get_converter` cache_key:
|
|
key = (
|
|
str(kwargs.get("table_mode", "fast")).lower(),
|
|
bool(kwargs.get("tables", True)),
|
|
bool(kwargs.get("allow_ocr", True)),
|
|
kwargs.get("artifacts_path"),
|
|
)
|
|
cached = docling_parser._converter_cache.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
new = sentinel_a if key[0] == "fast" else sentinel_b
|
|
docling_parser._converter_cache[key] = new
|
|
return new
|
|
|
|
with patch.object(DoclingParser, "_get_converter", side_effect=fake_build):
|
|
a1 = docling_parser._get_converter()
|
|
a2 = docling_parser._get_converter()
|
|
b = docling_parser._get_converter(table_mode="accurate")
|
|
|
|
assert a1 is a2 is sentinel_a
|
|
assert b is sentinel_b
|
|
assert a1 is not b
|
|
|
|
|
|
def test_windows_libreoffice_candidates_include_standard_install_dir(
|
|
monkeypatch, tmp_path
|
|
):
|
|
monkeypatch.setattr(parser_module, "_IS_WINDOWS", True)
|
|
monkeypatch.setattr(parser_module.shutil, "which", lambda command: None)
|
|
monkeypatch.setenv("PROGRAMFILES", str(tmp_path))
|
|
monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
|
|
|
|
soffice_path = tmp_path / "LibreOffice" / "program" / "soffice.exe"
|
|
soffice_path.parent.mkdir(parents=True)
|
|
soffice_path.write_text("", encoding="utf-8")
|
|
|
|
candidates = Parser._libreoffice_command_candidates()
|
|
|
|
assert str(soffice_path) in candidates
|
|
|
|
|
|
def test_convert_office_to_pdf_uses_windows_standard_libreoffice_path(
|
|
monkeypatch, tmp_path
|
|
):
|
|
monkeypatch.setattr(parser_module, "_IS_WINDOWS", True)
|
|
monkeypatch.setattr(parser_module.subprocess, "CREATE_NO_WINDOW", 0, raising=False)
|
|
monkeypatch.setattr(parser_module.shutil, "which", lambda command: None)
|
|
monkeypatch.setenv("PROGRAMFILES", str(tmp_path))
|
|
monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
|
|
|
|
soffice_path = tmp_path / "LibreOffice" / "program" / "soffice.exe"
|
|
soffice_path.parent.mkdir(parents=True)
|
|
soffice_path.write_text("", encoding="utf-8")
|
|
|
|
doc_path = tmp_path / "sample.docx"
|
|
doc_path.write_bytes(b"docx")
|
|
output_dir = tmp_path / "out"
|
|
captured_commands = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured_commands.append(cmd)
|
|
if cmd[0] != str(soffice_path):
|
|
raise FileNotFoundError(cmd[0])
|
|
|
|
pdf_path = Path(cmd[cmd.index("--outdir") + 1]) / "sample.pdf"
|
|
pdf_path.write_bytes(b"%PDF-1.4\n" + b"0" * 200)
|
|
return MagicMock(returncode=0, stderr="")
|
|
|
|
monkeypatch.setattr(parser_module.subprocess, "run", fake_run)
|
|
|
|
pdf_path = Parser.convert_office_to_pdf(doc_path, output_dir)
|
|
|
|
assert captured_commands[-1][0] == str(soffice_path)
|
|
assert pdf_path == output_dir / "sample.pdf"
|
|
assert pdf_path.exists()
|