Files
2026-07-13 12:08:54 +08:00

270 lines
8.8 KiB
Python

"""Tests for the unified parser debug CLI (``lightrag/parser/cli.py``).
The CLI behaviour under test is engine-agnostic: argument parsing, the
flat sidecar layout (no ``__parsed__/`` middle layer), the lenient raw
cache strategy (non-empty raw_dir reused without manifest checks), and
the no-archive guarantee on the source file.
We drive these checks via the **docling** engine path because docling's
raw bundle is the easiest to construct as static fixture (a single JSON
file) with zero external service or fixture-file dependency. The other
two engines exercise the same CLI code path:
- ``native`` would need a real ``.docx`` byte stream end-to-end (golden
fixtures live under ``tests/parser/docx/golden/`` and have
their own coverage via ``test_native_docx_golden.py``).
- ``mineru`` would need to mock ``MinerURawClient.download_into`` on
the cache-miss path, or seed a mineru raw bundle layout (more files
than docling's). Cache-hit reuses the same CLI orchestration as
docling, so coverage here implicitly validates mineru's CLI wiring
too.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
from lightrag.parser.cli import main
def _make_main_json(
*,
origin_filename: str = "demo.pdf",
with_table: bool = False,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"schema_name": "DoclingDocument",
"version": "1.10.0",
"origin": {"filename": origin_filename, "mimetype": "application/pdf"},
"body": {
"self_ref": "#/body",
"children": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}],
"content_layer": "body",
"label": "unspecified",
},
"groups": [],
"texts": [
{
"self_ref": "#/texts/0",
"label": "title",
"text": "Hello Title",
"orig": "Hello Title",
"content_layer": "body",
"prov": [],
},
{
"self_ref": "#/texts/1",
"label": "text",
"text": "Body line.",
"orig": "Body line.",
"content_layer": "body",
"prov": [],
},
],
"pictures": [],
"tables": [],
"key_value_items": [],
"form_items": [],
}
if with_table:
payload["body"]["children"].append({"$ref": "#/tables/0"})
payload["tables"].append(
{
"self_ref": "#/tables/0",
"label": "table",
"content_layer": "body",
"data": {
"num_rows": 1,
"num_cols": 2,
"grid": [[{"text": "A"}, {"text": "B"}]],
},
"prov": [],
}
)
return payload
def _seed_raw_dir(raw_dir: Path, *, with_table: bool = False) -> None:
raw_dir.mkdir(parents=True, exist_ok=True)
(raw_dir / "demo.json").write_text(
json.dumps(_make_main_json(with_table=with_table)),
encoding="utf-8",
)
def _read_meta(blocks_path: Path) -> dict[str, Any]:
return json.loads(blocks_path.read_text(encoding="utf-8").splitlines()[0])
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (
"DOCLING_BBOX_ATTRIBUTES",
"DOCLING_ENGINE_VERSION",
"LIGHTRAG_FORCE_REPARSE_DOCLING",
):
monkeypatch.delenv(name, raising=False)
def test_cli_writes_sidecar_from_existing_raw_dir(tmp_path: Path) -> None:
source = tmp_path / "demo.pdf"
source.write_bytes(b"%PDF-1.4\n") # never read; presence is the only check
_seed_raw_dir(tmp_path / "demo.pdf.docling_raw", with_table=True)
rc = main([str(source), "--engine", "docling"])
assert rc == 0
parsed_dir = tmp_path / "demo.pdf.parsed"
blocks_path = parsed_dir / "demo.blocks.jsonl"
assert blocks_path.is_file()
assert (parsed_dir / "demo.tables.json").is_file()
meta = _read_meta(blocks_path)
assert meta["parse_engine"] == "docling"
assert meta["document_name"] == "demo.pdf"
assert meta["table_file"] is True
# Source file stays where it was — the CLI mocks the archive step.
assert source.is_file()
def test_cli_doc_id_default_is_stable_across_runs(tmp_path: Path) -> None:
source = tmp_path / "demo.pdf"
source.write_bytes(b"%PDF-1.4\n")
_seed_raw_dir(tmp_path / "demo.pdf.docling_raw")
blocks_path = tmp_path / "demo.pdf.parsed" / "demo.blocks.jsonl"
assert main([str(source), "--engine", "docling"]) == 0
first_lines = blocks_path.read_text(encoding="utf-8").splitlines()
first_meta = json.loads(first_lines[0])
first_block_ids = [json.loads(line)["blockid"] for line in first_lines[1:]]
assert main([str(source), "--engine", "docling"]) == 0
second_lines = blocks_path.read_text(encoding="utf-8").splitlines()
second_meta = json.loads(second_lines[0])
second_block_ids = [json.loads(line)["blockid"] for line in second_lines[1:]]
assert first_meta["doc_id"].startswith("doc-")
assert first_meta["doc_id"] == second_meta["doc_id"]
assert first_block_ids and first_block_ids == second_block_ids
def test_cli_doc_id_override(tmp_path: Path) -> None:
source = tmp_path / "demo.pdf"
source.write_bytes(b"%PDF-1.4\n")
_seed_raw_dir(tmp_path / "demo.pdf.docling_raw", with_table=True)
override = "doc-" + "a" * 32
rc = main([str(source), "--engine", "docling", "--doc-id", override])
assert rc == 0
parsed_dir = tmp_path / "demo.pdf.parsed"
meta = _read_meta(parsed_dir / "demo.blocks.jsonl")
assert meta["doc_id"] == override
tables = json.loads((parsed_dir / "demo.tables.json").read_text(encoding="utf-8"))[
"tables"
]
assert tables
assert all(tid.startswith("tb-" + "a" * 32 + "-") for tid in tables)
def test_cli_custom_sidecar_parent_dir(tmp_path: Path) -> None:
source = tmp_path / "demo.pdf"
source.write_bytes(b"%PDF-1.4\n")
custom_parent = tmp_path / "elsewhere"
custom_parent.mkdir()
_seed_raw_dir(custom_parent / "demo.pdf.docling_raw")
rc = main([str(source), "--engine", "docling", "-o", str(custom_parent)])
assert rc == 0
assert (custom_parent / "demo.pdf.parsed" / "demo.blocks.jsonl").is_file()
# Nothing should land in the source's parent directory.
assert not (tmp_path / "demo.pdf.parsed").exists()
# Source file is preserved in place.
assert source.is_file()
def test_cli_missing_input_file_returns_error(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
missing = tmp_path / "nope.pdf"
rc = main([str(missing), "--engine", "docling"])
assert rc == 1
err = capsys.readouterr().err
assert str(missing.resolve()) in err
def test_cli_rejects_suffix_engine_mismatch(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
# native only accepts .docx; feeding it a .pdf should fail up-front with
# a clear error rather than crashing deep inside the IR builder.
source = tmp_path / "demo.pdf"
source.write_bytes(b"%PDF-1.4\n")
rc = main([str(source), "--engine", "native"])
assert rc == 1
err = capsys.readouterr().err
assert "native" in err
assert "pdf" in err
assert "docx" in err # supported suffix list mentions docx
def test_cli_legacy_engine_prints_raw_summary(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
# legacy is registry-selectable and emits plain content with no sidecar
# (blocks_path=""). The CLI must summarize the content instead of trying
# to open a non-existent blocks file.
source = tmp_path / "notes.txt"
source.write_text("hello world\nsecond line\n", encoding="utf-8")
rc = main([str(source), "--engine", "legacy"])
assert rc == 0
out = capsys.readouterr().out
assert "engine : legacy" in out
assert "format : raw" in out
assert "hello world" in out
def test_cli_direct_script_native_does_not_shadow_python_docx(
tmp_path: Path,
) -> None:
from docx import Document
source = tmp_path / "demo.docx"
doc = Document()
doc.add_heading("Hello Title", level=1)
doc.add_paragraph("Body line.")
doc.save(source)
repo_root = Path(__file__).resolve().parents[2]
cli_path = repo_root / "lightrag" / "parser" / "cli.py"
proc = subprocess.run(
[
sys.executable,
str(cli_path),
str(source),
"--engine",
"native",
"--preview",
"0",
],
cwd=repo_root,
text=True,
capture_output=True,
check=False,
)
assert proc.returncode == 0, proc.stderr + proc.stdout
assert "python-docx not installed" not in proc.stderr
assert (tmp_path / "demo.docx.parsed" / "demo.blocks.jsonl").is_file()
assert source.is_file()