chore: import upstream snapshot with attribution
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
"""Tests for ``lightrag/parser/external/docling/cache.py``.
|
||||
|
||||
Covers the cache-miss conditions enumerated in the module docstring:
|
||||
|
||||
- missing / malformed / wrong-engine manifest
|
||||
- source size or hash mismatch
|
||||
- engine_version / endpoint_signature env mismatch
|
||||
- options_signature env mismatch
|
||||
- critical-file size / sha256 mismatch
|
||||
- non-critical file size mismatch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external import Manifest, ManifestFile, write_manifest
|
||||
from lightrag.parser.external._common import compute_size_and_hash
|
||||
from lightrag.parser.external.docling.cache import (
|
||||
compute_options_signature,
|
||||
is_bundle_valid,
|
||||
snapshot_tunable_env,
|
||||
)
|
||||
from lightrag.parser.external.docling.client import FIXED_CONSTANTS
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_envs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in (
|
||||
"DOCLING_DO_OCR",
|
||||
"DOCLING_FORCE_OCR",
|
||||
"DOCLING_OCR_ENGINE",
|
||||
"DOCLING_OCR_PRESET",
|
||||
"DOCLING_OCR_LANG",
|
||||
"DOCLING_DO_FORMULA_ENRICHMENT",
|
||||
"DOCLING_ENGINE_VERSION",
|
||||
"DOCLING_ENDPOINT",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_file(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "src.pdf"
|
||||
p.write_bytes(b"hello pdf payload" * 64)
|
||||
return p
|
||||
|
||||
|
||||
def test_snapshot_tunable_env_uses_effective_defaults(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
unset = snapshot_tunable_env()
|
||||
|
||||
monkeypatch.setenv("DOCLING_DO_OCR", "true")
|
||||
monkeypatch.setenv("DOCLING_FORCE_OCR", "true")
|
||||
monkeypatch.setenv("DOCLING_OCR_ENGINE", "auto")
|
||||
monkeypatch.setenv("DOCLING_OCR_PRESET", "auto")
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", "")
|
||||
monkeypatch.setenv("DOCLING_DO_FORMULA_ENRICHMENT", "false")
|
||||
|
||||
assert snapshot_tunable_env() == unset
|
||||
|
||||
|
||||
def _build_valid_bundle(
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
*,
|
||||
options_signature: str | None = None,
|
||||
) -> Path:
|
||||
raw_dir = tmp_path / "src.docling_raw"
|
||||
raw_dir.mkdir()
|
||||
main_json = raw_dir / "src.json"
|
||||
main_json.write_text('{"schema_name": "DoclingDocument"}', encoding="utf-8")
|
||||
md = raw_dir / "src.md"
|
||||
md.write_text("# title", encoding="utf-8")
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file)
|
||||
crit_size, crit_hash = compute_size_and_hash(main_json)
|
||||
sig = options_signature
|
||||
if sig is None:
|
||||
sig = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
manifest = Manifest(
|
||||
engine="docling",
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file.name,
|
||||
critical_file=ManifestFile(path="src.json", size=crit_size, sha256=crit_hash),
|
||||
files=[ManifestFile(path="src.md", size=md.stat().st_size)],
|
||||
total_size_bytes=crit_size + md.stat().st_size,
|
||||
task_id="task-1",
|
||||
endpoint_signature="http://docling.test",
|
||||
engine_version="",
|
||||
options_signature=sig,
|
||||
extras={"fixed_constants": dict(FIXED_CONSTANTS)},
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return raw_dir
|
||||
|
||||
|
||||
def test_is_bundle_valid_happy_path(tmp_path: Path, source_file: Path) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
|
||||
|
||||
def test_is_bundle_valid_missing_dir(tmp_path: Path, source_file: Path) -> None:
|
||||
assert is_bundle_valid(tmp_path / "ghost", source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_missing_manifest(tmp_path: Path, source_file: Path) -> None:
|
||||
raw = tmp_path / "src.docling_raw"
|
||||
raw.mkdir()
|
||||
(raw / "src.json").write_text("{}")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_wrong_engine(tmp_path: Path, source_file: Path) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
manifest_path = raw / "_manifest.json"
|
||||
data = manifest_path.read_text(encoding="utf-8")
|
||||
manifest_path.write_text(data.replace('"docling"', '"mineru"'), encoding="utf-8")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_source_size_mismatch(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
source_file.write_bytes(source_file.read_bytes() + b"!")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_source_hash_mismatch(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
# Replace contents with same length but different bytes
|
||||
new = b"Y" * source_file.stat().st_size
|
||||
source_file.write_bytes(new)
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_endpoint_change(
|
||||
tmp_path: Path, source_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://other:5001")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_options_signature_change(
|
||||
tmp_path: Path, source_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
monkeypatch.setenv("DOCLING_FORCE_OCR", "false")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_legacy_bundle_misses_with_overrides(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
# A legacy bundle predating signature recording (empty options_signature)
|
||||
# is leniently accepted with no overrides, but a per-file override
|
||||
# (docling(force_ocr=...)) must force a miss — we cannot prove the bundle
|
||||
# was produced with that override, and silently reusing it would drop the
|
||||
# user's explicit param. Mirrors MinerU, which misses on any absent sig.
|
||||
raw = _build_valid_bundle(tmp_path, source_file, options_signature="")
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
assert is_bundle_valid(raw, source_file, overrides={"force_ocr": False}) is False
|
||||
assert is_bundle_valid(raw, source_file, overrides={"force_ocr": True}) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_fixed_constants_code_change(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
# Simulate a code-only change to one of the fixed pipeline constants
|
||||
# (e.g. image_export_mode flipped from "referenced" to "embedded"
|
||||
# between parse time and validation time). The manifest stores both
|
||||
# the stale constants and a signature computed from them; validation
|
||||
# must compare against current FIXED_CONSTANTS and miss, not against
|
||||
# the manifest's own copy (which would always match).
|
||||
stale_constants = {**FIXED_CONSTANTS, "image_export_mode": "embedded"}
|
||||
stale_signature = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=stale_constants,
|
||||
)
|
||||
raw = _build_valid_bundle(tmp_path, source_file, options_signature=stale_signature)
|
||||
# Overwrite the manifest's extras to record the stale constants too —
|
||||
# this is the bug surface: if validation rehydrated from extras, it
|
||||
# would reproduce stale_signature and falsely accept the bundle.
|
||||
import json as _json
|
||||
|
||||
mp = raw / "_manifest.json"
|
||||
data = _json.loads(mp.read_text(encoding="utf-8"))
|
||||
data["extras"] = {"fixed_constants": stale_constants}
|
||||
mp.write_text(_json.dumps(data), encoding="utf-8")
|
||||
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_critical_file_corrupt(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
# Corrupt the JSON: same length, different bytes — defeats size check,
|
||||
# so the sha256 path must catch it.
|
||||
current = (raw / "src.json").read_bytes()
|
||||
(raw / "src.json").write_bytes(b"X" * len(current))
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_other_file_size_mismatch(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
(raw / "src.md").write_text("totally different content here that is longer")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
"""Tests for :class:`DoclingRawClient`.
|
||||
|
||||
Cover the contract guarantees that protect the sidecar pipeline:
|
||||
|
||||
- the fixed pipeline constants (``pipeline=standard`` / ``target_type=zip``
|
||||
/ ``to_formats=[json,md]`` / ``image_export_mode=referenced``) are sent
|
||||
on every upload, regardless of env;
|
||||
- terminal non-success states (``failure`` / ``partial_success`` /
|
||||
``skipped``) abort the run **before** any result download;
|
||||
- ``DOCLING_OCR_LANG`` is omitted when empty so docling-serve falls back
|
||||
to its own default.
|
||||
|
||||
Uses an in-process fake httpx client mirroring ``tests/parser/external/mineru/test_client.py``
|
||||
so we don't trip httpx's sync/async stream guard on multipart uploads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external.docling.client import (
|
||||
CONVERT_PATH,
|
||||
POLL_PATH,
|
||||
RESULT_PATH,
|
||||
DoclingRawClient,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal httpx fake (no MockTransport — avoids the multipart encode path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
text: str = "",
|
||||
content: bytes = b"",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.content = content or text.encode("utf-8")
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self) -> Any:
|
||||
return json.loads(self.text) if self.text else {}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
terminal_status: str,
|
||||
zip_bytes: bytes,
|
||||
task_id: str = "task-abc",
|
||||
submit_status_code: int = 200,
|
||||
submit_text: str | None = None,
|
||||
poll_status_code: int = 200,
|
||||
poll_text: str | None = None,
|
||||
result_status_code: int = 200,
|
||||
result_text: str | None = None,
|
||||
) -> None:
|
||||
self.terminal_status = terminal_status
|
||||
self.zip_bytes = zip_bytes
|
||||
self.task_id = task_id
|
||||
self.submit_status_code = submit_status_code
|
||||
self.submit_text = submit_text
|
||||
self.poll_status_code = poll_status_code
|
||||
self.poll_text = poll_text
|
||||
self.result_status_code = result_status_code
|
||||
self.result_text = result_text
|
||||
|
||||
self.post_calls: list[dict] = []
|
||||
self.get_calls: list[dict] = []
|
||||
self.result_calls = 0
|
||||
|
||||
|
||||
_CURRENT: dict[str, _Recorder] = {}
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *_: Any, **__: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
pass
|
||||
|
||||
async def post(
|
||||
self,
|
||||
url: str,
|
||||
files: Any = None,
|
||||
data: Any = None,
|
||||
json: Any = None,
|
||||
headers: Any = None,
|
||||
) -> _FakeResponse:
|
||||
recorder = _CURRENT["recorder"]
|
||||
# Production passes a file handle inside a `with` block — by the time
|
||||
# tests inspect `post_calls` it's already closed. Drain the stream
|
||||
# here so assertions can keep reading the payload as bytes.
|
||||
snapshot_files = files
|
||||
if files and "files" in files:
|
||||
name, payload, ctype = files["files"]
|
||||
if hasattr(payload, "read"):
|
||||
payload = payload.read()
|
||||
snapshot_files = {"files": (name, payload, ctype)}
|
||||
recorder.post_calls.append(
|
||||
{"url": url, "files": snapshot_files, "data": data, "json": json}
|
||||
)
|
||||
if CONVERT_PATH in url:
|
||||
if recorder.submit_status_code != 200:
|
||||
return _FakeResponse(
|
||||
status_code=recorder.submit_status_code,
|
||||
text=recorder.submit_text or "",
|
||||
)
|
||||
return _FakeResponse(
|
||||
status_code=200,
|
||||
text=json_dump({"task_id": recorder.task_id}),
|
||||
)
|
||||
raise AssertionError(f"unexpected POST {url}")
|
||||
|
||||
async def get(
|
||||
self, url: str, params: Any = None, headers: Any = None
|
||||
) -> _FakeResponse:
|
||||
recorder = _CURRENT["recorder"]
|
||||
recorder.get_calls.append({"url": url, "params": params})
|
||||
# Mirror production: the client encodes the task id into a single path
|
||||
# segment, so route on the encoded form (a no-op for URL-safe ids).
|
||||
encoded = quote(recorder.task_id, safe="")
|
||||
if POLL_PATH.format(task_id=encoded) in url:
|
||||
if recorder.poll_status_code != 200:
|
||||
return _FakeResponse(
|
||||
status_code=recorder.poll_status_code,
|
||||
text=recorder.poll_text or "",
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"task_id": recorder.task_id,
|
||||
"task_status": recorder.terminal_status,
|
||||
}
|
||||
if recorder.terminal_status != "success":
|
||||
payload["error_message"] = "synthetic-failure"
|
||||
return _FakeResponse(status_code=200, text=json_dump(payload))
|
||||
if RESULT_PATH.format(task_id=encoded) in url:
|
||||
recorder.result_calls += 1
|
||||
if recorder.result_status_code != 200:
|
||||
return _FakeResponse(
|
||||
status_code=recorder.result_status_code,
|
||||
text=recorder.result_text or "",
|
||||
)
|
||||
return _FakeResponse(
|
||||
status_code=200,
|
||||
content=recorder.zip_bytes,
|
||||
headers={"content-type": "application/zip"},
|
||||
)
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
|
||||
|
||||
def json_dump(payload: Any) -> str:
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
def _form_pairs(data: Any) -> list[tuple[str, str]]:
|
||||
"""Normalize httpx form data into repeated ``(name, value)`` pairs.
|
||||
|
||||
Production passes a mapping so httpx 0.28 keeps multipart ``files=`` on
|
||||
the async path. List values in that mapping represent repeated form keys.
|
||||
Older tests used tuple lists directly; accepting both keeps assertions
|
||||
focused on the wire contract instead of the container type.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for name, value in data.items():
|
||||
values = value if isinstance(value, list) else [value]
|
||||
pairs.extend((str(name), str(v)) for v in values)
|
||||
return pairs
|
||||
return [(str(name), str(value)) for name, value in data]
|
||||
|
||||
|
||||
def _fake_zip_with_main_json(stem: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(f"{stem}.json", b'{"schema_name": "DoclingDocument"}')
|
||||
zf.writestr(f"{stem}.md", b"# hello")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _install_fake_httpx(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Replace ``httpx.AsyncClient`` and ``httpx.Timeout`` references in
|
||||
the docling client module with no-arg fakes."""
|
||||
monkeypatch.setattr(
|
||||
"lightrag.parser.external.docling.client.httpx.AsyncClient",
|
||||
_FakeAsyncClient,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"lightrag.parser.external.docling.client.httpx.Timeout",
|
||||
lambda *a, **kw: None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_pdf(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "demo.pdf"
|
||||
p.write_bytes(b"%PDF-1.4 fake")
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def docling_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
for name in (
|
||||
"DOCLING_DO_OCR",
|
||||
"DOCLING_FORCE_OCR",
|
||||
"DOCLING_OCR_ENGINE",
|
||||
"DOCLING_OCR_PRESET",
|
||||
"DOCLING_OCR_LANG",
|
||||
"DOCLING_DO_FORMULA_ENRICHMENT",
|
||||
"DOCLING_ENGINE_VERSION",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_docling_client_sends_fixed_constants(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
raw_dir = tmp_path / "demo.docling_raw"
|
||||
manifest = await DoclingRawClient().download_into(raw_dir, source_pdf)
|
||||
|
||||
assert len(recorder.post_calls) == 1
|
||||
data = recorder.post_calls[0]["data"]
|
||||
field_map: dict[str, list[str]] = {}
|
||||
for name, value in _form_pairs(data):
|
||||
field_map.setdefault(name, []).append(value)
|
||||
|
||||
assert field_map["pipeline"] == ["standard"]
|
||||
assert field_map["target_type"] == ["zip"]
|
||||
assert field_map["image_export_mode"] == ["referenced"]
|
||||
assert sorted(field_map["to_formats"]) == ["json", "md"]
|
||||
|
||||
files = recorder.post_calls[0]["files"]
|
||||
assert "files" in files
|
||||
name, blob, ctype = files["files"]
|
||||
assert name == "demo.pdf"
|
||||
assert blob.startswith(b"%PDF-1.4")
|
||||
assert ctype == "application/octet-stream"
|
||||
|
||||
assert manifest.task_id == recorder.task_id
|
||||
assert manifest.engine == "docling"
|
||||
assert manifest.extras["fixed_constants"]["pipeline"] == "standard"
|
||||
assert manifest.endpoint_signature == "http://docling.test"
|
||||
|
||||
|
||||
async def test_docling_client_partial_success_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="partial_success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
msg = str(excinfo.value)
|
||||
assert recorder.task_id in msg
|
||||
assert "partial_success" in msg
|
||||
assert "synthetic-failure" in msg
|
||||
assert recorder.result_calls == 0
|
||||
|
||||
|
||||
async def test_docling_client_failure_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="failure",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
assert recorder.result_calls == 0
|
||||
|
||||
|
||||
async def test_docling_client_skipped_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="skipped",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
assert recorder.result_calls == 0
|
||||
|
||||
|
||||
async def test_docling_client_upload_http_error_preserves_response_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
submit_status_code=400,
|
||||
submit_text=json_dump({"detail": "unsupported file type"}),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling upload for 'demo.pdf'" in message
|
||||
assert "HTTP 400" in message
|
||||
assert "unsupported file type" in message
|
||||
|
||||
|
||||
async def test_docling_client_poll_http_error_preserves_response_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
poll_status_code=503,
|
||||
poll_text=json_dump({"message": "queue unavailable"}),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling task task-abc poll" in message
|
||||
assert "HTTP 503" in message
|
||||
assert "queue unavailable" in message
|
||||
|
||||
|
||||
async def test_docling_client_result_redirect_treated_as_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
# docling-serve fronted by a misconfigured proxy could emit a 302 to a
|
||||
# CDN that httpx (default ``follow_redirects=False``) won't follow.
|
||||
# Without the explicit non-2xx guard the redirect body would fall into
|
||||
# the zip-decoder and surface as a cryptic "bad zip" error.
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
result_status_code=302,
|
||||
result_text="",
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling result task-abc download" in message
|
||||
assert "HTTP 302" in message
|
||||
|
||||
|
||||
async def test_docling_client_result_http_error_preserves_response_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
result_status_code=500,
|
||||
result_text="zip artifact missing",
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling result task-abc download" in message
|
||||
assert "HTTP 500" in message
|
||||
assert "zip artifact missing" in message
|
||||
|
||||
|
||||
async def test_docling_client_encodes_task_id_into_url_path_segment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
# Security regression (CWE-116): the poll/result task id is service-returned.
|
||||
# A crafted value with ``/`` / ``?`` / ``..`` must be percent-encoded into a
|
||||
# single path segment so it cannot escape ``/v1/status/poll/{id}`` or append
|
||||
# a query string to the request the client issues.
|
||||
malicious = "../admin?x=1"
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
task_id=malicious,
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
poll_url = recorder.get_calls[0]["url"]
|
||||
result_url = recorder.get_calls[-1]["url"]
|
||||
for url in (poll_url, result_url):
|
||||
# ``..`` survives (dot is unreserved) but the separators that grant
|
||||
# request-structure control are neutralized.
|
||||
assert "..%2Fadmin%3Fx%3D1" in url
|
||||
assert "/admin" not in url
|
||||
assert "?x=1" not in url
|
||||
|
||||
|
||||
async def test_docling_client_ocr_lang_omitted_when_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
data = recorder.post_calls[0]["data"]
|
||||
names = [name for name, _ in _form_pairs(data)]
|
||||
assert "ocr_lang" not in names
|
||||
|
||||
|
||||
async def test_docling_client_ocr_lang_sent_when_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", '["en","zh"]')
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
data = recorder.post_calls[0]["data"]
|
||||
langs = [v for name, v in _form_pairs(data) if name == "ocr_lang"]
|
||||
assert langs == ["en", "zh"]
|
||||
|
||||
|
||||
async def test_docling_client_ocr_lang_csv_form(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
"""CSV fallback when value isn't valid JSON."""
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", "en, fr")
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
data = recorder.post_calls[0]["data"]
|
||||
langs = [v for name, v in _form_pairs(data) if name == "ocr_lang"]
|
||||
assert langs == ["en", "fr"]
|
||||
|
||||
|
||||
async def test_docling_client_rejects_missing_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "")
|
||||
with pytest.raises(ValueError, match="DOCLING_ENDPOINT"):
|
||||
DoclingRawClient()
|
||||
|
||||
|
||||
async def test_docling_client_strips_parser_hint_from_upload_filename(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
# Regression: a hinted source (``report.[docling].pdf``) used to cause
|
||||
# docling-serve to name its bundle JSON ``report.[docling].json``, which
|
||||
# the adapter (looking for ``report.json``) could not locate. The
|
||||
# pipeline now passes the canonical name as ``upload_filename`` so the
|
||||
# bundle is canonical-stem from the start.
|
||||
hinted = tmp_path / "report.[docling].pdf"
|
||||
hinted.write_bytes(b"%PDF-1.4 fake")
|
||||
# The fake zip mimics docling-serve responding with the *canonical* stem,
|
||||
# which is what would happen once we send the canonical filename.
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("report"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
raw_dir = tmp_path / "report.docling_raw"
|
||||
manifest = await DoclingRawClient().download_into(
|
||||
raw_dir, hinted, upload_filename="report.pdf"
|
||||
)
|
||||
|
||||
name, _blob, _ctype = recorder.post_calls[0]["files"]["files"]
|
||||
assert name == "report.pdf"
|
||||
assert manifest.source_filename_at_parse == "report.pdf"
|
||||
assert manifest.critical_file.path == "report.json"
|
||||
assert (raw_dir / "report.json").is_file()
|
||||
|
||||
|
||||
async def test_docling_client_default_upload_filename_falls_back_to_source_name(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, source_pdf: Path
|
||||
) -> None:
|
||||
# Back-compat guard: callers that don't pass ``upload_filename`` (any
|
||||
# path other than the production pipeline) keep the legacy behavior of
|
||||
# using the on-disk source filename.
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
name, _blob, _ctype = recorder.post_calls[0]["files"]["files"]
|
||||
assert name == "demo.pdf"
|
||||
+1138
File diff suppressed because it is too large
Load Diff
+57
@@ -0,0 +1,57 @@
|
||||
"""Tests for ``lightrag/parser/external/docling/manifest.py`` helpers.
|
||||
|
||||
Targets the contract guarantees that the rest of the docling flow relies on:
|
||||
``select_main_json`` must find the bundle's main JSON even when ``_manifest.json``
|
||||
sits alongside it, and the preferred-path lookup must take priority over the
|
||||
fallback glob.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external.docling.manifest import select_main_json
|
||||
|
||||
|
||||
def _touch(path: Path, content: str = "{}") -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_select_main_json_preferred_path_hits(tmp_path: Path) -> None:
|
||||
# Manifest is present (the typical post-download state), but the preferred
|
||||
# ``<stem>.json`` exists, so the fallback glob is not consulted at all.
|
||||
_touch(tmp_path / "report.json")
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
assert select_main_json(tmp_path, Path("report.pdf")) == tmp_path / "report.json"
|
||||
|
||||
|
||||
def test_select_main_json_fallback_ignores_manifest(tmp_path: Path) -> None:
|
||||
# Defensive: when the preferred path misses (e.g. docling-serve renamed
|
||||
# the stem for whatever reason), the fallback glob must NOT confuse
|
||||
# ``_manifest.json`` for a bundle JSON. Pre-fix this case raised
|
||||
# "multiple .json candidates".
|
||||
_touch(tmp_path / "report.json")
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
assert select_main_json(tmp_path, Path("other.pdf")) == tmp_path / "report.json"
|
||||
|
||||
|
||||
def test_select_main_json_raises_when_only_manifest_present(tmp_path: Path) -> None:
|
||||
# If the bundle JSON is genuinely missing, the manifest alone is not a
|
||||
# valid substitute — the helper must still raise rather than silently
|
||||
# returning the manifest.
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
with pytest.raises(RuntimeError, match="contains no .json file"):
|
||||
select_main_json(tmp_path, Path("report.pdf"))
|
||||
|
||||
|
||||
def test_select_main_json_raises_on_real_ambiguity(tmp_path: Path) -> None:
|
||||
# Two genuine bundle JSONs is still an error; the manifest filter must
|
||||
# not mask multi-candidate detection.
|
||||
_touch(tmp_path / "report.json")
|
||||
_touch(tmp_path / "extra.json")
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
with pytest.raises(RuntimeError, match="multiple .json candidates"):
|
||||
select_main_json(tmp_path, Path("other.pdf"))
|
||||
@@ -0,0 +1,668 @@
|
||||
"""Integration tests for ``parse_docling`` with the unified sidecar pipeline.
|
||||
|
||||
Stubs :class:`DoclingRawClient.download_into` so no real docling-serve is
|
||||
contacted; the focus is on:
|
||||
|
||||
- happy path: cache miss → fake bundle written → sidecar emitted with all
|
||||
expected files at the spec-compliant locations
|
||||
- cache hit: a pre-existing valid ``*.docling_raw/`` + manifest causes
|
||||
``DoclingRawClient.download_into`` NOT to be called
|
||||
- ``LIGHTRAG_FORCE_REPARSE_DOCLING=true`` forces a re-download even when
|
||||
the manifest is valid
|
||||
- source content swap → cache miss
|
||||
- options_signature change (``DOCLING_OCR_LANG`` toggle) → cache miss
|
||||
- adapter sees zero blocks → parse fails loudly (no half-baked sidecar)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from lightrag import LightRAG
|
||||
from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG
|
||||
from lightrag.parser.external import (
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
compute_size_and_hash,
|
||||
write_manifest,
|
||||
)
|
||||
from lightrag.parser.external.docling.cache import (
|
||||
compute_options_signature,
|
||||
snapshot_tunable_env,
|
||||
)
|
||||
from lightrag.parser.external.docling.client import FIXED_CONSTANTS
|
||||
from lightrag.parser.base import ParseContext
|
||||
from lightrag.parser.registry import get_parser
|
||||
from lightrag.utils import EmbeddingFunc, Tokenizer
|
||||
|
||||
|
||||
async def _parse_via_registry(rag, engine, doc_id, file_path, content_data):
|
||||
"""Drive a parser the way the pipeline worker does (registry dispatch)."""
|
||||
result = await get_parser(engine).parse(
|
||||
ParseContext(rag, doc_id, file_path, content_data)
|
||||
)
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
class _SimpleTokenizerImpl:
|
||||
def encode(self, content: str) -> list[int]:
|
||||
return [ord(ch) for ch in content]
|
||||
|
||||
def decode(self, tokens: list[int]) -> str:
|
||||
return "".join(chr(t) for t in tokens)
|
||||
|
||||
|
||||
async def _mock_embedding(texts: list[str]) -> np.ndarray:
|
||||
return np.random.rand(len(texts), 32)
|
||||
|
||||
|
||||
async def _mock_llm(prompt: Any, **kwargs: Any) -> str:
|
||||
return '{"name":"x","summary":"s","detail_description":"d"}'
|
||||
|
||||
|
||||
def _new_rag(tmp_path: Path) -> LightRAG:
|
||||
return LightRAG(
|
||||
working_dir=str(tmp_path),
|
||||
workspace=f"test-docling-sidecar-{tmp_path.name}",
|
||||
llm_model_func=_mock_llm,
|
||||
embedding_func=EmbeddingFunc(
|
||||
embedding_dim=32,
|
||||
max_token_size=4096,
|
||||
func=_mock_embedding,
|
||||
),
|
||||
tokenizer=Tokenizer("mock-tokenizer", _SimpleTokenizerImpl()),
|
||||
vlm_process_enable=False,
|
||||
)
|
||||
|
||||
|
||||
_FAKE_DOCLING_JSON = {
|
||||
"schema_name": "DoclingDocument",
|
||||
"version": "1.10.0",
|
||||
"origin": {"filename": "demo.pdf", "mimetype": "application/pdf"},
|
||||
"body": {
|
||||
"self_ref": "#/body",
|
||||
"children": [
|
||||
{"$ref": "#/texts/0"},
|
||||
],
|
||||
"content_layer": "body",
|
||||
"label": "unspecified",
|
||||
},
|
||||
"groups": [],
|
||||
"texts": [
|
||||
{
|
||||
"self_ref": "#/texts/0",
|
||||
"label": "section_header",
|
||||
"text": "Intro",
|
||||
"orig": "Intro",
|
||||
"level": 1,
|
||||
"content_layer": "body",
|
||||
"children": [
|
||||
{"$ref": "#/texts/1"},
|
||||
{"$ref": "#/tables/0"},
|
||||
{"$ref": "#/pictures/0"},
|
||||
{"$ref": "#/texts/2"},
|
||||
],
|
||||
"prov": [
|
||||
{
|
||||
"page_no": 1,
|
||||
"bbox": {
|
||||
"l": 10.0,
|
||||
"t": 100.0,
|
||||
"r": 200.0,
|
||||
"b": 80.0,
|
||||
"coord_origin": "BOTTOMLEFT",
|
||||
},
|
||||
"charspan": [0, 5],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"self_ref": "#/texts/1",
|
||||
"label": "text",
|
||||
"text": "Body paragraph.",
|
||||
"orig": "Body paragraph.",
|
||||
"content_layer": "body",
|
||||
"prov": [
|
||||
{
|
||||
"page_no": 1,
|
||||
"bbox": {
|
||||
"l": 10.0,
|
||||
"t": 60.0,
|
||||
"r": 200.0,
|
||||
"b": 40.0,
|
||||
"coord_origin": "BOTTOMLEFT",
|
||||
},
|
||||
"charspan": [0, 15],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"self_ref": "#/texts/2",
|
||||
"label": "formula",
|
||||
"text": "E = mc^2",
|
||||
"orig": "E = mc^2",
|
||||
"content_layer": "body",
|
||||
"prov": [],
|
||||
},
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"self_ref": "#/tables/0",
|
||||
"label": "table",
|
||||
"content_layer": "body",
|
||||
"data": {
|
||||
"num_rows": 2,
|
||||
"num_cols": 2,
|
||||
"grid": [
|
||||
[{"text": "h1"}, {"text": "h2"}],
|
||||
[{"text": "a"}, {"text": "b"}],
|
||||
],
|
||||
},
|
||||
"prov": [],
|
||||
}
|
||||
],
|
||||
"pictures": [
|
||||
{
|
||||
"self_ref": "#/pictures/0",
|
||||
"label": "picture",
|
||||
"content_layer": "body",
|
||||
"image": {"uri": "artifacts/img_000000.png", "mimetype": "image/png"},
|
||||
"prov": [],
|
||||
}
|
||||
],
|
||||
"key_value_items": [],
|
||||
"form_items": [],
|
||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}, "page_no": 1}},
|
||||
}
|
||||
|
||||
|
||||
def _install_fake_download(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]:
|
||||
"""Replace ``DoclingRawClient.download_into`` with a recorder that
|
||||
writes a synthetic raw bundle and a valid manifest."""
|
||||
import lightrag.parser.external.docling.client as client_mod
|
||||
|
||||
counters = {"calls": 0}
|
||||
|
||||
async def _fake_download(self, raw_dir: Path, source_file_path: Path, **_kwargs):
|
||||
counters["calls"] += 1
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_json = raw_dir / "demo.json"
|
||||
main_json.write_text(json.dumps(_FAKE_DOCLING_JSON), encoding="utf-8")
|
||||
(raw_dir / "demo.md").write_text("# fake md", encoding="utf-8")
|
||||
art = raw_dir / "artifacts"
|
||||
art.mkdir(exist_ok=True)
|
||||
(art / "img_000000.png").write_bytes(b"\x89PNG fake")
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file_path)
|
||||
crit_size, crit_hash = compute_size_and_hash(main_json)
|
||||
others = [
|
||||
ManifestFile(path="demo.md", size=(raw_dir / "demo.md").stat().st_size),
|
||||
ManifestFile(
|
||||
path="artifacts/img_000000.png",
|
||||
size=(art / "img_000000.png").stat().st_size,
|
||||
),
|
||||
]
|
||||
options_signature = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
manifest = Manifest(
|
||||
engine="docling",
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file_path.name,
|
||||
critical_file=ManifestFile(
|
||||
path="demo.json", size=crit_size, sha256=crit_hash
|
||||
),
|
||||
files=others,
|
||||
total_size_bytes=crit_size + sum(f.size for f in others),
|
||||
task_id=f"fake-{counters['calls']}",
|
||||
endpoint_signature="http://docling.test",
|
||||
options_signature=options_signature,
|
||||
extras={"fixed_constants": dict(FIXED_CONSTANTS)},
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return manifest
|
||||
|
||||
monkeypatch.setattr(client_mod.DoclingRawClient, "download_into", _fake_download)
|
||||
return counters
|
||||
|
||||
|
||||
def _stub_pipeline(monkeypatch: pytest.MonkeyPatch, rag: LightRAG, src: Path) -> None:
|
||||
"""Common pipeline-level stubs: avoid moving the source file and pin
|
||||
the file resolver to the synthetic path."""
|
||||
|
||||
async def _noop_archive(_p: str) -> None:
|
||||
return None
|
||||
|
||||
import lightrag.pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"archive_docx_source_after_full_docs_sync",
|
||||
_noop_archive,
|
||||
)
|
||||
monkeypatch.setattr(rag, "_resolve_source_file_for_parser", lambda _p: str(src))
|
||||
|
||||
|
||||
def _seed_doc_status(rag: LightRAG, doc_id: str) -> Any:
|
||||
return rag.doc_status.upsert(
|
||||
{
|
||||
doc_id: {
|
||||
"status": "PARSING",
|
||||
"content_summary": "",
|
||||
"content_length": 0,
|
||||
"chunks_count": 0,
|
||||
"chunks_list": [],
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
"file_path": "demo.pdf",
|
||||
"track_id": "trk",
|
||||
"content_hash": "",
|
||||
"metadata": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_emits_compliant_sidecar(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
parsed = await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
parsed_dir = Path(parsed["blocks_path"]).parent
|
||||
assert parsed["parse_format"] == FULL_DOCS_FORMAT_LIGHTRAG
|
||||
assert parsed_dir.name == "demo.pdf.parsed"
|
||||
|
||||
files = {p.name for p in parsed_dir.iterdir() if p.is_file()}
|
||||
assert "demo.blocks.jsonl" in files
|
||||
assert "demo.tables.json" in files
|
||||
assert "demo.drawings.json" in files
|
||||
assert "demo.equations.json" in files
|
||||
assert (parsed_dir / "demo.blocks.assets").is_dir()
|
||||
assert (parsed_dir / "demo.blocks.assets" / "img_000000.png").is_file()
|
||||
|
||||
blocks_raw = (parsed_dir / "demo.blocks.jsonl").read_text()
|
||||
lines = blocks_raw.splitlines()
|
||||
meta = json.loads(lines[0])
|
||||
rows = [json.loads(line) for line in lines[1:]]
|
||||
assert meta["parse_engine"] == "docling"
|
||||
assert meta["bbox_attributes"] == {"origin": "LEFTBOTTOM"}
|
||||
assert "max" not in meta["bbox_attributes"]
|
||||
assert "page_sizes" not in meta["bbox_attributes"]
|
||||
assert meta["table_file"] is True
|
||||
assert meta["drawing_file"] is True
|
||||
assert meta["equation_file"] is True
|
||||
# No label="title" in the fixture (matches the typical PDF case
|
||||
# where docling produces only section_headers) → doc_title falls
|
||||
# back to the document stem.
|
||||
assert meta["doc_title"] == "demo"
|
||||
|
||||
contents = " ".join(row.get("content", "") for row in rows)
|
||||
assert '<table id="tb-' in contents
|
||||
assert "<drawing" in contents
|
||||
assert "<equation" in contents
|
||||
|
||||
# Raw bundle preserved next to sidecar
|
||||
raw_dir = parsed_dir.parent / "demo.pdf.docling_raw"
|
||||
assert (raw_dir / "_manifest.json").is_file()
|
||||
assert (raw_dir / "demo.json").is_file()
|
||||
assert (raw_dir / "demo.md").is_file()
|
||||
assert (raw_dir / "artifacts" / "img_000000.png").is_file()
|
||||
|
||||
# Drawing path correctly resolved
|
||||
drawings = json.loads((parsed_dir / "demo.drawings.json").read_text())[
|
||||
"drawings"
|
||||
]
|
||||
(drawing_id, drawing_item) = next(iter(drawings.items()))
|
||||
assert drawing_id.startswith("im-")
|
||||
assert drawing_item["path"] == "demo.blocks.assets/img_000000.png"
|
||||
|
||||
# Table self_ref propagated
|
||||
tables = json.loads((parsed_dir / "demo.tables.json").read_text())["tables"]
|
||||
(_, table_item) = next(iter(tables.items()))
|
||||
assert table_item.get("self_ref") == "#/tables/0"
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_cache_hit_skips_download(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1, "cache hit must not re-download"
|
||||
|
||||
monkeypatch.setenv("LIGHTRAG_FORCE_REPARSE_DOCLING", "true")
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_cache_invalidates_on_source_change(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
data = src.read_bytes()
|
||||
src.write_bytes(b"\x00" + data[1:])
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_options_signature_invalidates_cache(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
# Flip an env var that participates in the options signature
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", "en,zh")
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2, (
|
||||
"DOCLING_OCR_LANG change must invalidate the bundle cache"
|
||||
)
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_endpoint_signature_invalidates_cache(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
# Pointing at a different docling-serve instance must not silently
|
||||
# reuse a bundle that was produced by the previous one.
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling-other.test")
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2, (
|
||||
"DOCLING_ENDPOINT change must invalidate the bundle cache"
|
||||
)
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_zero_blocks_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When the docling bundle yields no body blocks (e.g. everything was
|
||||
classified as furniture/background) ``parse_docling`` must fail loudly
|
||||
so the document is marked failed — never persist a half-baked sidecar.
|
||||
"""
|
||||
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
|
||||
# Install a fake download that writes a valid bundle whose body has
|
||||
# no children — the adapter then produces zero IR blocks.
|
||||
import lightrag.parser.external.docling.client as client_mod
|
||||
|
||||
empty_json: dict[str, Any] = {
|
||||
"schema_name": "DoclingDocument",
|
||||
"version": "1.10.0",
|
||||
"origin": {"filename": "demo.pdf", "mimetype": "application/pdf"},
|
||||
"body": {
|
||||
"self_ref": "#/body",
|
||||
"children": [],
|
||||
"content_layer": "body",
|
||||
"label": "unspecified",
|
||||
},
|
||||
"groups": [],
|
||||
"texts": [],
|
||||
"tables": [],
|
||||
"pictures": [],
|
||||
"key_value_items": [],
|
||||
"form_items": [],
|
||||
"pages": {},
|
||||
}
|
||||
|
||||
async def _fake_download(
|
||||
self, raw_dir: Path, source_file_path: Path, **_kwargs
|
||||
):
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_json = raw_dir / "demo.json"
|
||||
main_json.write_text(json.dumps(empty_json), encoding="utf-8")
|
||||
(raw_dir / "demo.md").write_text("# empty", encoding="utf-8")
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file_path)
|
||||
crit_size, crit_hash = compute_size_and_hash(main_json)
|
||||
others = [
|
||||
ManifestFile(path="demo.md", size=(raw_dir / "demo.md").stat().st_size),
|
||||
]
|
||||
options_signature = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
manifest = Manifest(
|
||||
engine="docling",
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file_path.name,
|
||||
critical_file=ManifestFile(
|
||||
path="demo.json", size=crit_size, sha256=crit_hash
|
||||
),
|
||||
files=others,
|
||||
total_size_bytes=crit_size + sum(f.size for f in others),
|
||||
task_id="fake-empty",
|
||||
endpoint_signature="http://docling.test",
|
||||
options_signature=options_signature,
|
||||
extras={"fixed_constants": dict(FIXED_CONSTANTS)},
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return manifest
|
||||
|
||||
monkeypatch.setattr(
|
||||
client_mod.DoclingRawClient, "download_into", _fake_download
|
||||
)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
with pytest.raises(ValueError, match="zero blocks"):
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
|
||||
# Sidecar must NOT have been emitted: ``write_sidecar`` is reached
|
||||
# only after the zero-blocks check, so no ``*.blocks.jsonl`` may
|
||||
# exist anywhere under the workspace.
|
||||
blocks_files = list(tmp_path.rglob("*.blocks.jsonl"))
|
||||
assert not blocks_files, (
|
||||
f"sidecar emitted despite zero-blocks failure: {blocks_files}"
|
||||
)
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
"""``*.mineru_raw/`` cache validation tests.
|
||||
|
||||
Covers every failure mode that triggers a re-download:
|
||||
|
||||
- missing / malformed manifest
|
||||
- source file size mismatch (fast-path)
|
||||
- source file content_hash mismatch
|
||||
- parser options signature missing / mismatch
|
||||
- engine version / endpoint env mismatch
|
||||
- critical_file (content_list.json) size or sha256 mismatch
|
||||
- any non-critical file size mismatch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external.mineru import (
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
clear_dir_contents,
|
||||
compute_size_and_hash,
|
||||
is_bundle_valid,
|
||||
raw_dir_for_parsed_dir,
|
||||
)
|
||||
from lightrag.parser.external.mineru.cache import current_mineru_options_signature
|
||||
from lightrag.parser.external.mineru.manifest import write_manifest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_file(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "src.pdf"
|
||||
p.write_bytes(b"Hello PDF" * 100)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_bundle(tmp_path: Path, source_file: Path) -> tuple[Path, Manifest]:
|
||||
"""Build a fully-valid bundle alongside ``source_file`` and return
|
||||
``(raw_dir, manifest)``."""
|
||||
raw = tmp_path / "src.mineru_raw"
|
||||
raw.mkdir()
|
||||
content_list = raw / "content_list.json"
|
||||
content_list.write_text('[{"type":"text","text":"hi"}]', encoding="utf-8")
|
||||
images = raw / "images"
|
||||
images.mkdir()
|
||||
(images / "img1.png").write_bytes(b"PNG" * 50)
|
||||
(images / "img2.png").write_bytes(b"PNG" * 60)
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file)
|
||||
crit_size, crit_hash = compute_size_and_hash(content_list)
|
||||
files = [
|
||||
ManifestFile(path="images/img1.png", size=(images / "img1.png").stat().st_size),
|
||||
ManifestFile(path="images/img2.png", size=(images / "img2.png").stat().st_size),
|
||||
]
|
||||
manifest = Manifest(
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file.name,
|
||||
critical_file=ManifestFile(
|
||||
path="content_list.json", size=crit_size, sha256=crit_hash
|
||||
),
|
||||
files=files,
|
||||
total_size_bytes=crit_size + sum(f.size for f in files),
|
||||
task_id="task-1",
|
||||
api_mode="local",
|
||||
options_signature=current_mineru_options_signature(),
|
||||
)
|
||||
write_manifest(raw, manifest)
|
||||
return raw, manifest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_raw_dir_naming(tmp_path: Path) -> None:
|
||||
parsed = tmp_path / "report.pdf.parsed"
|
||||
raw = raw_dir_for_parsed_dir(parsed)
|
||||
assert raw.name == "report.pdf.mineru_raw"
|
||||
assert raw.parent == parsed.parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation: happy path + every individual failure mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_is_bundle_valid_happy_path(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_manifest_missing(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
(raw / "_manifest.json").unlink()
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_manifest_malformed(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
(raw / "_manifest.json").write_text("not json")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_manifest_wrong_engine(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload["engine"] = "docling"
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_source_size_changes(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
# Append bytes to the source file — size diverges from manifest fast-path.
|
||||
raw, _ = fresh_bundle
|
||||
with source_file.open("ab") as fh:
|
||||
fh.write(b"x")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_source_hash_changes_but_size_same(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
"""In-place rewrite that preserves byte size but mutates content. The
|
||||
fast-path passes but the full hash check catches it."""
|
||||
raw, _ = fresh_bundle
|
||||
data = source_file.read_bytes()
|
||||
# Flip first byte; keep length identical.
|
||||
mutated = bytes([data[0] ^ 0xFF]) + data[1:]
|
||||
assert len(mutated) == len(data)
|
||||
source_file.write_bytes(mutated)
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_engine_version_mismatch(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload["engine_version"] = "magic-pdf 1.5.4"
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
monkeypatch.setenv("MINERU_ENGINE_VERSION", "magic-pdf 1.6.0")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_engine_version_match_passes(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload["engine_version"] = "magic-pdf 1.5.4"
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
monkeypatch.setenv("MINERU_ENGINE_VERSION", "magic-pdf 1.5.4")
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_engine_version_skip_when_either_side_blank(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Blank manifest engine_version + non-blank env should NOT invalidate
|
||||
(no signal from manifest); same for the reverse."""
|
||||
raw, _ = fresh_bundle
|
||||
# Manifest engine_version is empty by default.
|
||||
monkeypatch.setenv("MINERU_ENGINE_VERSION", "anything")
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_api_mode_mismatch(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload["api_mode"] = "local"
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
monkeypatch.setenv("MINERU_API_MODE", "official")
|
||||
monkeypatch.setenv("MINERU_API_TOKEN", "token")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_options_signature_missing(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload.pop("options_signature", None)
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
("MINERU_LOCAL_BACKEND", "pipeline"),
|
||||
("MINERU_LOCAL_PARSE_METHOD", "ocr"),
|
||||
("MINERU_LOCAL_IMAGE_ANALYSIS", "true"),
|
||||
("MINERU_LOCAL_START_PAGE_ID", "1"),
|
||||
],
|
||||
)
|
||||
def test_invalid_when_local_parser_options_change(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
key: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
monkeypatch.setenv("MINERU_API_MODE", "local")
|
||||
monkeypatch.setenv(key, value)
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
("MINERU_MODEL_VERSION", "pipeline"),
|
||||
("MINERU_IS_OCR", "true"),
|
||||
("MINERU_PAGE_RANGES", "1-5"),
|
||||
("MINERU_LANGUAGE", "en"),
|
||||
("MINERU_ENABLE_TABLE", "false"),
|
||||
("MINERU_ENABLE_FORMULA", "false"),
|
||||
],
|
||||
)
|
||||
def test_invalid_when_official_parser_options_change(
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
key: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Symmetric coverage for the official-mode partition of the signature.
|
||||
|
||||
Build a bundle whose ``options_signature`` reflects the official defaults,
|
||||
sanity-check that it validates, then flip ``key`` and assert a cache miss.
|
||||
"""
|
||||
monkeypatch.setenv("MINERU_API_MODE", "official")
|
||||
|
||||
raw = tmp_path / "src.mineru_raw"
|
||||
raw.mkdir()
|
||||
content_list = raw / "content_list.json"
|
||||
content_list.write_text('[{"type":"text","text":"hi"}]', encoding="utf-8")
|
||||
crit_size, crit_hash = compute_size_and_hash(content_list)
|
||||
src_size, src_hash = compute_size_and_hash(source_file)
|
||||
manifest = Manifest(
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file.name,
|
||||
critical_file=ManifestFile(
|
||||
path="content_list.json", size=crit_size, sha256=crit_hash
|
||||
),
|
||||
files=[],
|
||||
total_size_bytes=crit_size,
|
||||
task_id="task-official",
|
||||
api_mode="official",
|
||||
options_signature=current_mineru_options_signature(),
|
||||
)
|
||||
write_manifest(raw, manifest)
|
||||
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
monkeypatch.setenv(key, value)
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_endpoint_signature_mismatch(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload["api_mode"] = "local"
|
||||
payload["endpoint_signature"] = "http://old.example"
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
monkeypatch.setenv("MINERU_API_MODE", "local")
|
||||
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://new.example")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_endpoint_signature_uses_mode_specific_endpoint(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload["api_mode"] = "local"
|
||||
payload["endpoint_signature"] = "http://old.example"
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
monkeypatch.setenv("MINERU_API_MODE", "local")
|
||||
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://new.example")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_endpoint_signature_ignores_trailing_slash(
|
||||
fresh_bundle: tuple[Path, Manifest],
|
||||
source_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
payload = json.loads((raw / "_manifest.json").read_text())
|
||||
payload["api_mode"] = "local"
|
||||
payload["endpoint_signature"] = "http://old.example"
|
||||
(raw / "_manifest.json").write_text(json.dumps(payload))
|
||||
monkeypatch.setenv("MINERU_API_MODE", "local")
|
||||
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://old.example/")
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_critical_file_missing(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
(raw / "content_list.json").unlink()
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_critical_file_size_changes(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
cl = raw / "content_list.json"
|
||||
cl.write_text(cl.read_text() + "/* extra */")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_critical_file_hash_changes(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
"""Same size, different bytes. sha256 is the terminal check."""
|
||||
raw, _ = fresh_bundle
|
||||
cl = raw / "content_list.json"
|
||||
data = cl.read_text()
|
||||
mutated = data[:-1] + "X" # swap last char; size preserved
|
||||
assert len(mutated) == len(data)
|
||||
cl.write_text(mutated)
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_aux_file_size_changes(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
p = raw / "images" / "img1.png"
|
||||
p.write_bytes(p.read_bytes() + b"corruption")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_invalid_when_aux_file_missing(
|
||||
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
||||
) -> None:
|
||||
raw, _ = fresh_bundle
|
||||
(raw / "images" / "img2.png").unlink()
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_clear_dir_contents_preserves_directory(tmp_path: Path) -> None:
|
||||
d = tmp_path / "raw"
|
||||
d.mkdir()
|
||||
(d / "a.txt").write_text("a")
|
||||
(d / "sub").mkdir()
|
||||
(d / "sub" / "b.txt").write_text("b")
|
||||
clear_dir_contents(d)
|
||||
assert d.exists()
|
||||
assert list(d.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_compute_size_and_hash_consistency(tmp_path: Path) -> None:
|
||||
"""Both values describe the same byte stream."""
|
||||
p = tmp_path / "f.bin"
|
||||
payload = b"abc" * 1000
|
||||
p.write_bytes(payload)
|
||||
size, h = compute_size_and_hash(p)
|
||||
assert size == len(payload)
|
||||
assert h.startswith("sha256:") and len(h) == len("sha256:") + 64
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_manifest_round_trip_via_disk(tmp_path: Path) -> None:
|
||||
"""Write → read recovers all fields."""
|
||||
raw = tmp_path / "rt.mineru_raw"
|
||||
raw.mkdir()
|
||||
m = Manifest(
|
||||
source_content_hash="sha256:abc",
|
||||
source_size_bytes=10,
|
||||
source_filename_at_parse="x.pdf",
|
||||
critical_file=ManifestFile(
|
||||
path="content_list.json", size=5, sha256="sha256:cl"
|
||||
),
|
||||
files=[ManifestFile(path="images/i.png", size=3)],
|
||||
total_size_bytes=8,
|
||||
task_id="t1",
|
||||
engine_version="v",
|
||||
endpoint_signature="ep",
|
||||
options_signature="sha256:opts",
|
||||
)
|
||||
write_manifest(raw, m)
|
||||
from lightrag.parser.external.mineru.manifest import load_manifest
|
||||
|
||||
loaded = load_manifest(raw)
|
||||
assert loaded is not None
|
||||
assert loaded.source_content_hash == "sha256:abc"
|
||||
assert loaded.critical_file.sha256 == "sha256:cl"
|
||||
assert [f.path for f in loaded.files] == ["images/i.png"]
|
||||
assert loaded.task_id == "t1"
|
||||
assert loaded.options_signature == "sha256:opts"
|
||||
+1071
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
"""``delete_file_variants_by_file_path`` now also clears the
|
||||
MinerU raw bundle (``*.mineru_raw/``) alongside the sidecar
|
||||
(``*.parsed/``) and source file when ``delete_file=True`` is selected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# document_routes pulls in api.auth → api.config.parse_args(); blank argv
|
||||
# to keep argparse from rejecting pytest's flags at import time.
|
||||
sys.argv = sys.argv[:1]
|
||||
|
||||
from lightrag.api.routers.document_routes import ( # noqa: E402
|
||||
_file_path_for_parsed_artifact_dir,
|
||||
delete_file_variants_by_file_path,
|
||||
)
|
||||
from lightrag.constants import PARSED_DIR_NAME # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_canonical_basename_recognizes_both_suffixes() -> None:
|
||||
assert _file_path_for_parsed_artifact_dir("foo.pdf.parsed") == "foo.pdf"
|
||||
assert _file_path_for_parsed_artifact_dir("foo.pdf.mineru_raw") == "foo.pdf"
|
||||
# archive variants (parsed_001, mineru_raw_002, ...) handled
|
||||
assert _file_path_for_parsed_artifact_dir("foo.pdf.parsed_001") == "foo.pdf"
|
||||
assert _file_path_for_parsed_artifact_dir("foo.pdf.mineru_raw_002") == "foo.pdf"
|
||||
# unrelated names don't match
|
||||
assert _file_path_for_parsed_artifact_dir("foo.parsed.bak") is None
|
||||
assert _file_path_for_parsed_artifact_dir("notes.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_delete_file_variants_removes_parsed_and_mineru_raw(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Both sidecar dir and raw bundle dir should disappear in one call."""
|
||||
input_dir = tmp_path / "inputs"
|
||||
input_dir.mkdir()
|
||||
parsed_root = input_dir / PARSED_DIR_NAME
|
||||
parsed_root.mkdir()
|
||||
|
||||
# Original source file (already moved into __parsed__/ post-archive).
|
||||
archived_source = parsed_root / "demo.pdf"
|
||||
archived_source.write_bytes(b"PDFCONTENT")
|
||||
|
||||
parsed_dir = parsed_root / "demo.pdf.parsed"
|
||||
parsed_dir.mkdir()
|
||||
(parsed_dir / "demo.blocks.jsonl").write_text("{}\n")
|
||||
|
||||
raw_dir = parsed_root / "demo.pdf.mineru_raw"
|
||||
raw_dir.mkdir()
|
||||
(raw_dir / "_manifest.json").write_text("{}")
|
||||
(raw_dir / "content_list.json").write_text("[]")
|
||||
(raw_dir / "images").mkdir()
|
||||
(raw_dir / "images" / "img.png").write_bytes(b"png")
|
||||
|
||||
deleted, errors = delete_file_variants_by_file_path(input_dir, file_path="demo.pdf")
|
||||
|
||||
# Both directories and the archived source file were deleted.
|
||||
assert errors == []
|
||||
deleted_names = {Path(p).name for p in deleted}
|
||||
assert "demo.pdf.parsed" in deleted_names
|
||||
assert "demo.pdf.mineru_raw" in deleted_names
|
||||
assert "demo.pdf" in deleted_names
|
||||
|
||||
assert not parsed_dir.exists()
|
||||
assert not raw_dir.exists()
|
||||
assert not archived_source.exists()
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_delete_file_variants_handles_only_raw_dir(tmp_path: Path) -> None:
|
||||
"""The raw bundle may exist without a corresponding sidecar (parse
|
||||
aborted between download and adapter). Delete should still pick it up.
|
||||
"""
|
||||
input_dir = tmp_path / "inputs"
|
||||
input_dir.mkdir()
|
||||
parsed_root = input_dir / PARSED_DIR_NAME
|
||||
parsed_root.mkdir()
|
||||
|
||||
raw_dir = parsed_root / "demo.pdf.mineru_raw"
|
||||
raw_dir.mkdir()
|
||||
(raw_dir / "_manifest.json").write_text("{}")
|
||||
|
||||
deleted, errors = delete_file_variants_by_file_path(input_dir, file_path="demo.pdf")
|
||||
assert errors == []
|
||||
assert any("demo.pdf.mineru_raw" in p for p in deleted)
|
||||
assert not raw_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_delete_file_variants_leaves_unrelated_dirs(tmp_path: Path) -> None:
|
||||
"""Directories that don't match the canonical filename are untouched."""
|
||||
input_dir = tmp_path / "inputs"
|
||||
input_dir.mkdir()
|
||||
parsed_root = input_dir / PARSED_DIR_NAME
|
||||
parsed_root.mkdir()
|
||||
|
||||
target_raw = parsed_root / "demo.pdf.mineru_raw"
|
||||
target_raw.mkdir()
|
||||
other_raw = parsed_root / "other.pdf.mineru_raw"
|
||||
other_raw.mkdir()
|
||||
other_parsed = parsed_root / "other.pdf.parsed"
|
||||
other_parsed.mkdir()
|
||||
|
||||
deleted, errors = delete_file_variants_by_file_path(input_dir, file_path="demo.pdf")
|
||||
assert errors == []
|
||||
assert not target_raw.exists()
|
||||
assert other_raw.exists(), "siblings for a different basename must survive"
|
||||
assert other_parsed.exists()
|
||||
+909
@@ -0,0 +1,909 @@
|
||||
"""MinerU IR builder tests: content_list.json → IR translation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external.mineru import MinerUIRBuilder
|
||||
|
||||
|
||||
def _write_bundle(tmp_path: Path, content_list: list[dict]) -> Path:
|
||||
"""Build a minimal *.mineru_raw/ directory."""
|
||||
raw = tmp_path / "doc.mineru_raw"
|
||||
raw.mkdir()
|
||||
(raw / "content_list.json").write_text(json.dumps(content_list, ensure_ascii=False))
|
||||
return raw
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_simple_text_and_heading(tmp_path: Path) -> None:
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "1 Introduction", "text_level": 1},
|
||||
{"type": "text", "text": "Body paragraph."},
|
||||
{"type": "text", "text": "1.1 Sub", "text_level": 2},
|
||||
{"type": "text", "text": "Sub body."},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="x.pdf")
|
||||
|
||||
assert ir.doc_title == "1 Introduction"
|
||||
assert ir.document_format == "pdf"
|
||||
# Heading + body merge into a single block per heading.
|
||||
assert len(ir.blocks) == 2
|
||||
assert ir.blocks[0].heading == "1 Introduction"
|
||||
assert ir.blocks[0].level == 1
|
||||
# Heading line is rendered with markdown ``#`` prefix matching the level.
|
||||
assert ir.blocks[0].content_template == "# 1 Introduction\nBody paragraph."
|
||||
# Sub-heading updates stack and records parent.
|
||||
assert ir.blocks[1].heading == "1.1 Sub"
|
||||
assert ir.blocks[1].level == 2
|
||||
assert ir.blocks[1].parent_headings == ["1 Introduction"]
|
||||
assert ir.blocks[1].content_template == "## 1.1 Sub\nSub body."
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_preface_block_for_pre_heading_content(tmp_path: Path) -> None:
|
||||
"""Items emitted before the first heading land in a synthetic
|
||||
``Preface/Uncategorized`` block at level 0."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "Floating intro line."},
|
||||
{"type": "list", "list_items": ["a", "b"]},
|
||||
{"type": "text", "text": "Section A", "text_level": 1},
|
||||
{"type": "text", "text": "A body."},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="p.pdf")
|
||||
|
||||
assert len(ir.blocks) == 2
|
||||
preface = ir.blocks[0]
|
||||
assert preface.heading == "Preface/Uncategorized"
|
||||
assert preface.level == 0
|
||||
assert preface.parent_headings == []
|
||||
assert preface.content_template == "Floating intro line.\na\nb"
|
||||
|
||||
section = ir.blocks[1]
|
||||
assert section.heading == "Section A"
|
||||
assert section.level == 1
|
||||
assert section.content_template == "# Section A\nA body."
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_merges_mixed_payloads_under_heading(tmp_path: Path) -> None:
|
||||
"""Tables / images / equations / code under the same heading merge into
|
||||
one block; their placeholders appear in document order."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "Methods", "text_level": 1},
|
||||
{"type": "text", "text": "We did stuff."},
|
||||
{
|
||||
"type": "table",
|
||||
"table_body": [["a", "b"], ["1", "2"]],
|
||||
"num_rows": 2,
|
||||
"num_cols": 2,
|
||||
},
|
||||
{"type": "image", "img_path": "images/fig1.png"},
|
||||
{"type": "equation", "text": "$$E = mc^2$$"},
|
||||
{"type": "code", "code_body": "print('ok')"},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="m.pdf")
|
||||
assert len(ir.blocks) == 1
|
||||
block = ir.blocks[0]
|
||||
assert block.heading == "Methods"
|
||||
assert block.level == 1
|
||||
assert len(block.tables) == 1
|
||||
assert len(block.drawings) == 1
|
||||
assert len(block.equations) == 1
|
||||
# Lines are joined in source order; the heading carries its ``#`` prefix.
|
||||
expected_lines = [
|
||||
"# Methods",
|
||||
"We did stuff.",
|
||||
f"{{{{TBL:{block.tables[0].placeholder_key}}}}}",
|
||||
f"{{{{IMG:{block.drawings[0].placeholder_key}}}}}",
|
||||
f"{{{{EQ:{block.equations[0].placeholder_key}}}}}",
|
||||
"print('ok')",
|
||||
]
|
||||
assert block.content_template == "\n".join(expected_lines)
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_table_and_drawing_and_equation(tmp_path: Path) -> None:
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "table",
|
||||
"table_body": [["a", "b"], ["1", "2"]],
|
||||
"num_rows": 2,
|
||||
"num_cols": 2,
|
||||
"table_caption": ["Tbl"],
|
||||
"header": [["a", "b"]],
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"img_path": "images/img_001.jpg",
|
||||
"image_caption": ["Fig 1"],
|
||||
"page_idx": 1,
|
||||
"bbox": [10, 20, 30, 40],
|
||||
},
|
||||
{"type": "equation", "text": "$E = mc^2$", "caption": "Eq 1"},
|
||||
],
|
||||
)
|
||||
# The drawing references images/img_001.jpg — adapter accepts missing
|
||||
# files and produces an AssetSpec with source=None.
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="d.pdf")
|
||||
|
||||
table_block = next(b for b in ir.blocks if b.tables)
|
||||
table = table_block.tables[0]
|
||||
assert table.rows == [["a", "b"], ["1", "2"]]
|
||||
assert table.num_rows == 2 and table.num_cols == 2
|
||||
assert table.caption == "Tbl"
|
||||
assert table.table_header == [["a", "b"]]
|
||||
assert table.self_ref == "content_list.json#/0"
|
||||
|
||||
drawing_block = next(b for b in ir.blocks if b.drawings)
|
||||
drawing = drawing_block.drawings[0]
|
||||
assert drawing.fmt == "jpg"
|
||||
assert drawing.caption == "Fig 1"
|
||||
assert drawing.self_ref == "content_list.json#/1"
|
||||
# Position carried through. The bbox-bearing item produces exactly one
|
||||
# fine-grained position (anchor + range) and is NOT also rolled into the
|
||||
# page-only summary channel — so the block has a single position entry,
|
||||
# not a duplicate summary + bbox pair.
|
||||
assert len(drawing_block.positions) == 1
|
||||
assert drawing_block.positions[0].type == "bbox"
|
||||
# Anchor is always serialized as a string (uniform on-disk format,
|
||||
# accommodates book pagination labels like Roman "ii").
|
||||
assert drawing_block.positions[0].anchor == "2" # page_idx+1
|
||||
assert drawing_block.positions[0].range == [10.0, 20.0, 30.0, 40.0]
|
||||
|
||||
# Asset is declared with the relative path as ref.
|
||||
assert any(a.ref == "images/img_001.jpg" for a in ir.assets)
|
||||
|
||||
equation_block = next(b for b in ir.blocks if b.equations)
|
||||
eq = equation_block.equations[0]
|
||||
# IREquation.latex preserves MinerU's raw form so blocks.jsonl shows it
|
||||
# verbatim; equations.json strips the ``$`` wrappers downstream (writer).
|
||||
assert eq.latex == "$E = mc^2$"
|
||||
assert eq.is_block is True
|
||||
assert eq.caption == "Eq 1"
|
||||
assert eq.self_ref == "content_list.json#/2"
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_page_idx_aggregated_and_deduped_when_no_bbox(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Real MinerU output carries ``page_idx`` on every item but rarely a
|
||||
``bbox``. Each unique page contributing to a merged block must surface as
|
||||
one anchor-only ``{type:"bbox", anchor:<page+1>}`` entry, sorted, no
|
||||
duplicates, no ``range``.
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "Section", "text_level": 1, "page_idx": 0},
|
||||
{"type": "text", "text": "line A", "page_idx": 0},
|
||||
{"type": "text", "text": "line B", "page_idx": 1},
|
||||
{"type": "text", "text": "line C", "page_idx": 1},
|
||||
{"type": "text", "text": "line D", "page_idx": 2},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="p.pdf")
|
||||
|
||||
assert len(ir.blocks) == 1
|
||||
block = ir.blocks[0]
|
||||
# Pages 0, 1, 2 → anchors "1", "2", "3" — one entry per unique page.
|
||||
# Anchors are persisted as strings for on-disk uniformity.
|
||||
assert len(block.positions) == 3
|
||||
anchors = [p.anchor for p in block.positions]
|
||||
assert anchors == ["1", "2", "3"]
|
||||
for pos in block.positions:
|
||||
assert pos.type == "bbox"
|
||||
# Page-only summary entries have no range; ``to_jsonable`` must omit
|
||||
# the key entirely.
|
||||
assert pos.range is None
|
||||
assert "range" not in pos.to_jsonable()
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_bbox_items_and_page_only_items_coexist(tmp_path: Path) -> None:
|
||||
"""When a block merges both bbox-bearing and bbox-less items, the bbox
|
||||
items are emitted per-item (no dedupe, with ``range``) and only the
|
||||
bbox-less items contribute to the page-only summary. Ordering: summary
|
||||
first (sorted by anchor), bbox entries after (source order).
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "Mixed", "text_level": 1, "page_idx": 1},
|
||||
{
|
||||
"type": "image",
|
||||
"img_path": "images/fig.png",
|
||||
"page_idx": 1,
|
||||
"bbox": [10, 20, 30, 40],
|
||||
},
|
||||
{"type": "text", "text": "tail line", "page_idx": 2},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="m.pdf")
|
||||
|
||||
assert len(ir.blocks) == 1
|
||||
positions = ir.blocks[0].positions
|
||||
# One page-only summary for page 3 (the bbox-less tail line) and one
|
||||
# bbox entry for page 2 (the image). The heading item has page_idx=1
|
||||
# but no bbox, so it adds anchor 2 to the page set — combined with the
|
||||
# tail item's anchor 3 the summary section has TWO anchors (1+1, 2+1).
|
||||
assert [(p.anchor, p.range) for p in positions] == [
|
||||
("2", None),
|
||||
("3", None),
|
||||
("2", [10.0, 20.0, 30.0, 40.0]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_page_sort_books_convention_with_mixed_anchors(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Block merges items with Roman preface labels and Arabic numerals.
|
||||
|
||||
Two guarantees:
|
||||
|
||||
1. The adapter must not crash when sorting heterogeneous anchors — a
|
||||
previous bug surfaced ``TypeError: '<' not supported between
|
||||
instances of 'str' and 'int'`` whenever ``page_idx`` mixed types.
|
||||
2. Output order follows book pagination convention: Roman / letter
|
||||
labels first (lexical), then numeric pages by integer value, so
|
||||
``"2"`` precedes ``"10"`` (not ``"10"`` before ``"2"`` as a naive
|
||||
lexical sort would do).
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Mixed Pagination",
|
||||
"text_level": 1,
|
||||
"page_idx": "i",
|
||||
},
|
||||
{"type": "text", "text": "preface intro", "page_idx": "i"},
|
||||
{"type": "text", "text": "preface tail", "page_idx": "ii"},
|
||||
{"type": "text", "text": "chapter line A", "page_idx": 1}, # → "2"
|
||||
{"type": "text", "text": "chapter line B", "page_idx": 9}, # → "10"
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="mix.pdf")
|
||||
|
||||
assert len(ir.blocks) == 1
|
||||
anchors = [p.anchor for p in ir.blocks[0].positions]
|
||||
# Roman labels first (lex order), then numerics by int value.
|
||||
assert anchors == ["i", "ii", "2", "10"]
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_empty_text_item_does_not_leak_page_to_block(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An item whose body is empty must NOT contribute its ``page_idx`` to
|
||||
the current block's positions — otherwise spurious pages from
|
||||
content-less items poison provenance.
|
||||
|
||||
Regression: empty text on page 99 sits between two real headings; its
|
||||
page must not appear under either block.
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "Section A", "text_level": 1, "page_idx": 0},
|
||||
{"type": "text", "text": "real body", "page_idx": 0},
|
||||
# Empty body — should be silently dropped, page_idx not recorded.
|
||||
{"type": "text", "text": "", "page_idx": 98},
|
||||
{"type": "text", "text": "Section B", "text_level": 1, "page_idx": 1},
|
||||
{"type": "text", "text": "next body", "page_idx": 1},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="leak.pdf")
|
||||
|
||||
assert len(ir.blocks) == 2
|
||||
a_anchors = [p.anchor for p in ir.blocks[0].positions]
|
||||
b_anchors = [p.anchor for p in ir.blocks[1].positions]
|
||||
# Section A only mentions page 1 (page_idx 0 + 1) — NOT 99 from the
|
||||
# dropped empty item.
|
||||
assert a_anchors == ["1"]
|
||||
assert "99" not in a_anchors and "99" not in b_anchors
|
||||
# Section B only mentions page 2 (page_idx 1 + 1).
|
||||
assert b_anchors == ["2"]
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_each_heading_starts_its_own_block(tmp_path: Path) -> None:
|
||||
"""Every recognized heading starts its own block. Back-to-back headings
|
||||
with no body between them are NOT folded — each becomes a standalone
|
||||
block whose content is just the heading line; the heading that does have
|
||||
a following body merges that body in. Mirrors the native docx parser.
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "1 Top", "text_level": 1},
|
||||
{"type": "text", "text": "1.1 Mid", "text_level": 2},
|
||||
{"type": "text", "text": "1.1.1 Deep", "text_level": 3},
|
||||
{"type": "text", "text": "Body for deep."},
|
||||
{"type": "text", "text": "2 Top Again", "text_level": 1},
|
||||
{"type": "text", "text": "More body."},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="m.pdf")
|
||||
|
||||
summary = [
|
||||
(b.heading, b.content_template, b.level, b.parent_headings) for b in ir.blocks
|
||||
]
|
||||
assert summary == [
|
||||
("1 Top", "# 1 Top", 1, []),
|
||||
("1.1 Mid", "## 1.1 Mid", 2, ["1 Top"]),
|
||||
(
|
||||
"1.1.1 Deep",
|
||||
"### 1.1.1 Deep\nBody for deep.",
|
||||
3,
|
||||
["1 Top", "1.1 Mid"],
|
||||
),
|
||||
("2 Top Again", "# 2 Top Again\nMore body.", 1, []),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_heading_markdown_prefix_cap_and_existing_marker(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Heading prefix is capped at six ``#`` and a heading whose text already
|
||||
carries a markdown prefix is not double-prefixed in content but is stored
|
||||
cleanly in metadata."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
# level 7 → clamped to six "#".
|
||||
{"type": "text", "text": "Deep", "text_level": 7},
|
||||
{"type": "text", "text": "deep body."},
|
||||
# Source text already a markdown heading → kept verbatim.
|
||||
{"type": "text", "text": "# Already MD", "text_level": 1},
|
||||
{"type": "text", "text": "md body."},
|
||||
{"type": "text", "text": "## Child MD", "text_level": 2},
|
||||
{"type": "text", "text": "child body."},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="m.pdf")
|
||||
|
||||
first_lines = [b.content_template.split("\n")[0] for b in ir.blocks]
|
||||
assert first_lines == ["###### Deep", "# Already MD", "## Child MD"]
|
||||
assert [(b.heading, b.parent_headings) for b in ir.blocks] == [
|
||||
("Deep", []),
|
||||
("Already MD", []),
|
||||
("Child MD", ["Already MD"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_adjacent_shallower_heading_starts_new_block(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Inverse case: when the second adjacent heading is shallower (level
|
||||
number smaller or equal), it must NOT merge — it starts a new block.
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "1.1 Mid first", "text_level": 2},
|
||||
{"type": "text", "text": "2 Top after", "text_level": 1},
|
||||
{"type": "text", "text": "body"},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="m.pdf")
|
||||
|
||||
# The first block is heading-only; the writer downstream will keep it
|
||||
# (the merged-heading rule only forwards DEEPER headings).
|
||||
assert len(ir.blocks) == 2
|
||||
assert ir.blocks[0].heading == "1.1 Mid first"
|
||||
assert ir.blocks[0].level == 2
|
||||
assert ir.blocks[0].content_template == "## 1.1 Mid first"
|
||||
|
||||
assert ir.blocks[1].heading == "2 Top after"
|
||||
assert ir.blocks[1].level == 1
|
||||
assert ir.blocks[1].content_template == "# 2 Top after\nbody"
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_body_breaks_adjacent_heading_merge(tmp_path: Path) -> None:
|
||||
"""Once any body content lands in the current block, the next heading —
|
||||
even a deeper one — must flush and open a fresh block (no merge)."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "1 Top", "text_level": 1},
|
||||
{"type": "text", "text": "Intro line under 1."},
|
||||
{"type": "text", "text": "1.1 Mid", "text_level": 2},
|
||||
{"type": "text", "text": "Mid body."},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="m.pdf")
|
||||
|
||||
assert len(ir.blocks) == 2
|
||||
assert ir.blocks[0].content_template == "# 1 Top\nIntro line under 1."
|
||||
assert ir.blocks[1].heading == "1.1 Mid"
|
||||
assert ir.blocks[1].parent_headings == ["1 Top"]
|
||||
assert ir.blocks[1].content_template == "## 1.1 Mid\nMid body."
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_block_equation_preserves_dollar_wrappers(tmp_path: Path) -> None:
|
||||
"""Block equations keep the ``$$`` markers verbatim on IREquation.latex
|
||||
so the writer renders blocks.jsonl's ``<equation>`` body byte-identical
|
||||
to MinerU's source. The downstream writer is responsible for stripping
|
||||
them when generating equations.json."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "equation",
|
||||
"text": "$$\n\\int_0^1 x dx = \\tfrac{1}{2}\n$$",
|
||||
"text_format": "block",
|
||||
"caption": "Eq A",
|
||||
},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="b.pdf")
|
||||
eq = ir.blocks[0].equations[0]
|
||||
assert eq.is_block is True
|
||||
# No stripping in the adapter; whitespace.strip() only.
|
||||
assert eq.latex == "$$\n\\int_0^1 x dx = \\tfrac{1}{2}\n$$"
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_empty_equation_dropped(tmp_path: Path) -> None:
|
||||
"""Fix 2: equation items with empty text MUST NOT enter the IR (and
|
||||
consequently not the sidecar). They previously left dangling sidecar
|
||||
entries."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "equation", "text": "", "caption": "ghost"},
|
||||
{"type": "equation", "text": " ", "caption": "ghost"},
|
||||
{"type": "text", "text": "kept"},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="g.pdf")
|
||||
eq_count = sum(len(b.equations) for b in ir.blocks)
|
||||
assert eq_count == 0
|
||||
assert any(b.content_template == "kept" for b in ir.blocks)
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_empty_table_dropped(tmp_path: Path) -> None:
|
||||
"""Table items with no usable body MUST NOT enter the IR.
|
||||
|
||||
MinerU sometimes misidentifies a page-number / blank region as a table
|
||||
and emits a body-less ``table`` item (missing ``table_body``/``rows``,
|
||||
or with an empty string / empty grid). Leaving such items in the IR
|
||||
would later trip the analyze worker's hard-failure path on empty
|
||||
``content``. The IR builder filters them upstream.
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
# 1) Body field completely absent.
|
||||
{"type": "table", "num_rows": 0, "num_cols": 0},
|
||||
# 2) Empty string body (matches the real m012-manual.pdf bug).
|
||||
{"type": "table", "table_body": ""},
|
||||
# 3) Empty list body.
|
||||
{"type": "table", "rows": []},
|
||||
# 4) Grid with only blank cells.
|
||||
{"type": "table", "rows": [["", " "], ["\t", ""]]},
|
||||
# 5) A real text item so the IR is not entirely empty.
|
||||
{"type": "text", "text": "kept"},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="t.pdf")
|
||||
table_count = sum(len(b.tables) for b in ir.blocks)
|
||||
assert table_count == 0
|
||||
# No table placeholder should leak into the rendered content either.
|
||||
joined = "\n".join(b.content_template for b in ir.blocks)
|
||||
assert "TBL:" not in joined
|
||||
assert "kept" in joined
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_page_number_dropped(tmp_path: Path) -> None:
|
||||
"""``page_number`` items are layout noise and MUST NOT enter the IR.
|
||||
|
||||
MinerU emits a ``page_number`` item per page. Empty-text page numbers were
|
||||
already dropped by the fallback's _append_text guard, but page numbers that
|
||||
carry real text (``"12"``, ``"iii"``) previously leaked into block content
|
||||
and contaminated the block's positions with their page_idx. The IR builder
|
||||
now skips them outright regardless of text.
|
||||
"""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "text", "text": "kept", "page_idx": 0},
|
||||
# Page number carrying real text — the regression case.
|
||||
{"type": "page_number", "text": "12", "page_idx": 7},
|
||||
# Roman-numeral page number.
|
||||
{"type": "page_number", "text": "iii", "page_idx": 8},
|
||||
# Blank/whitespace page number.
|
||||
{"type": "page_number", "text": "- ", "page_idx": 9},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="p.pdf")
|
||||
joined = "\n".join(b.content_template for b in ir.blocks)
|
||||
assert "12" not in joined
|
||||
assert "iii" not in joined
|
||||
assert "kept" in joined
|
||||
# The page_idx of skipped page numbers must not leak into block positions.
|
||||
# Anchors are 1-based strings, so page_idx 7/8/9 would surface as 8/9/10.
|
||||
anchors = {pos.anchor for b in ir.blocks for pos in b.positions}
|
||||
assert anchors.isdisjoint({"8", "9", "10"})
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_bbox_attributes_default_and_override(tmp_path: Path) -> None:
|
||||
raw = _write_bundle(tmp_path, [{"type": "text", "text": "x"}])
|
||||
adapter = MinerUIRBuilder()
|
||||
ir = adapter.normalize_from_workdir(raw, document_name="x.pdf")
|
||||
assert ir.bbox_attributes == {"origin": "LEFTTOP", "max": 1000}
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_bbox_attributes_env_override(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"MINERU_BBOX_ATTRIBUTES",
|
||||
'{"origin": "LEFTBOTTOM", "max": 612}',
|
||||
)
|
||||
raw = _write_bundle(tmp_path, [{"type": "text", "text": "x"}])
|
||||
adapter = MinerUIRBuilder()
|
||||
ir = adapter.normalize_from_workdir(raw, document_name="x.pdf")
|
||||
assert ir.bbox_attributes == {"origin": "LEFTBOTTOM", "max": 612}
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_engine_version_recorded_in_split_option(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MINERU_ENGINE_VERSION", "magic-pdf 1.5.4")
|
||||
raw = _write_bundle(tmp_path, [{"type": "text", "text": "x"}])
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="x.pdf")
|
||||
assert ir.split_option == {"engine_version": "magic-pdf 1.5.4"}
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_missing_content_list_raises(tmp_path: Path) -> None:
|
||||
raw_dir = tmp_path / "bad.mineru_raw"
|
||||
raw_dir.mkdir()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
MinerUIRBuilder().normalize_from_workdir(raw_dir, document_name="x.pdf")
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_html_table_preserves_merged_cells_and_extracts_thead(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""MinerU HTML table_body must stay HTML so rowspan/colspan survive."""
|
||||
table_html = (
|
||||
'<table><thead><tr><th rowspan="2">Metric</th>'
|
||||
'<th colspan="2">Group</th></tr><tr><th>A</th><th>B</th></tr></thead>'
|
||||
"<tbody><tr><td>Accuracy</td><td>91%</td><td>93%</td></tr></tbody></table>"
|
||||
)
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "table",
|
||||
"table_body": table_html,
|
||||
"table_caption": ["Tbl"],
|
||||
}
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="h.pdf")
|
||||
table = ir.blocks[0].tables[0]
|
||||
|
||||
assert table.rows is None
|
||||
assert table.html == table_html
|
||||
assert 'rowspan="2"' in table.html
|
||||
assert 'colspan="2"' in table.html
|
||||
assert table.body_override == table_html.removeprefix("<table>").removesuffix(
|
||||
"</table>"
|
||||
)
|
||||
assert table.num_rows == 3
|
||||
assert table.num_cols == 3
|
||||
assert table.caption == "Tbl"
|
||||
# The <thead> is preserved verbatim (raw HTML) so rowspan/colspan survive —
|
||||
# not flattened into a grid.
|
||||
assert table.table_header == (
|
||||
'<thead><tr><th rowspan="2">Metric</th>'
|
||||
'<th colspan="2">Group</th></tr><tr><th>A</th><th>B</th></tr></thead>'
|
||||
)
|
||||
assert 'rowspan="2"' in table.table_header
|
||||
assert 'colspan="2"' in table.table_header
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_html_table_header_preserves_spans(tmp_path: Path) -> None:
|
||||
"""A spanned <thead> is stored verbatim — colspan/rowspan markup is kept
|
||||
intact rather than flattened into a rectangular grid (so merged-cell
|
||||
semantics survive into every repeated header chunk downstream)."""
|
||||
thead = (
|
||||
"<thead>"
|
||||
'<tr><th>ID</th><th colspan="3">Scores</th></tr>'
|
||||
'<tr><th>n</th><th>A</th><th colspan="2">BC</th></tr>'
|
||||
"</thead>"
|
||||
)
|
||||
table_html = (
|
||||
f"<table>{thead}<tbody>"
|
||||
"<tr><td>1</td><td>x</td><td>y</td><td>z</td></tr>"
|
||||
"</tbody></table>"
|
||||
)
|
||||
raw = _write_bundle(tmp_path, [{"type": "table", "table_body": table_html}])
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="h.pdf")
|
||||
table = ir.blocks[0].tables[0]
|
||||
assert table.num_cols == 4
|
||||
assert table.table_header == thead
|
||||
assert table.table_header.count('colspan="3"') == 1
|
||||
assert table.table_header.count('colspan="2"') == 1
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_html_table_without_thead_does_not_guess_header(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Only a real HTML <thead> should populate table_header."""
|
||||
table_html = (
|
||||
"<table><tbody><tr><td>H1</td><td>H2</td></tr>"
|
||||
"<tr><td>a</td><td>b</td></tr></tbody></table>"
|
||||
)
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "table",
|
||||
"table_body": table_html,
|
||||
"num_rows": 1,
|
||||
"num_cols": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="h.pdf")
|
||||
table = ir.blocks[0].tables[0]
|
||||
assert table.rows is None
|
||||
assert table.html == table_html
|
||||
assert table.table_header is None
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_html_table_without_thead_falls_back_to_header_grid(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An HTML table whose markup carries no <thead> but for which MinerU
|
||||
supplied a separate ``header`` grid keeps that grid (the writer renders it to
|
||||
a span-less <thead>) rather than silently dropping the recovered header."""
|
||||
table_html = (
|
||||
"<table><tbody><tr><td>a</td><td>b</td></tr></tbody></table>" # no <thead>
|
||||
)
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "table",
|
||||
"table_body": table_html,
|
||||
"header": [["H1", "H2"]],
|
||||
"num_rows": 1,
|
||||
"num_cols": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="h.pdf")
|
||||
table = ir.blocks[0].tables[0]
|
||||
assert table.html == table_html
|
||||
assert table.table_header == [["H1", "H2"]] # grid kept, not dropped
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_html_table_attr_with_gt_keeps_inner_body(tmp_path: Path) -> None:
|
||||
"""A ``>`` inside a ``<table>`` attribute must not truncate the open tag,
|
||||
so ``body_override`` still strips the full ``<table …>`` wrapper."""
|
||||
table_html = '<table data-x="a>b"><tbody><tr><td>v</td></tr></tbody></table>'
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[{"type": "table", "table_body": table_html}],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="h.pdf")
|
||||
table = ir.blocks[0].tables[0]
|
||||
assert table.html == table_html
|
||||
assert table.body_override == "<tbody><tr><td>v</td></tr></tbody>"
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_empty_html_table_falls_back_to_full_html(tmp_path: Path) -> None:
|
||||
"""A degenerate ``<table></table>`` has no inner body, so ``body_override``
|
||||
stays None and the writer renders ``table.html`` verbatim."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[{"type": "table", "table_body": "<table></table>"}],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="h.pdf")
|
||||
table = ir.blocks[0].tables[0]
|
||||
assert table.html == "<table></table>"
|
||||
assert table.body_override is None
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_html_table_strips_html_body_wrapper(tmp_path: Path) -> None:
|
||||
"""MinerU's table model may wrap output in ``<html><body>``; the adapter
|
||||
must unwrap to a single ``<table>`` so the writer does not nest tables and
|
||||
``TABLE_TAG_RE`` consumers are not truncated at the inner ``</table>``."""
|
||||
inner = (
|
||||
'<thead><tr><th colspan="2">G</th></tr></thead>'
|
||||
"<tbody><tr><td>a</td><td>b</td></tr></tbody>"
|
||||
)
|
||||
payload = f"<html><body><table>{inner}</table></body></html>"
|
||||
raw = _write_bundle(tmp_path, [{"type": "table", "table_body": payload}])
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="h.pdf")
|
||||
table = ir.blocks[0].tables[0]
|
||||
assert table.html == f"<table>{inner}</table>" # <html>/<body> wrapper gone
|
||||
assert table.body_override == inner # block text renders only the inner body
|
||||
assert table.num_cols == 2
|
||||
# The <thead> is kept verbatim (colspan preserved), not flattened to a grid.
|
||||
assert table.table_header == '<thead><tr><th colspan="2">G</th></tr></thead>'
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_list_items_joined_with_newline(tmp_path: Path) -> None:
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "list", "list_items": ["one", "two", "three"]},
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="l.pdf")
|
||||
assert ir.blocks[0].content_template == "one\ntwo\nthree"
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_drawing_asset_source_only_when_file_exists(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The adapter should declare an AssetSpec for the drawing in both
|
||||
cases, but ``source`` is set only when the bytes are on disk; the
|
||||
writer then warns and skips a missing-source asset."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "image", "img_path": "images/exists.png"},
|
||||
{"type": "image", "img_path": "images/missing.png"},
|
||||
],
|
||||
)
|
||||
(raw / "images").mkdir()
|
||||
(raw / "images" / "exists.png").write_bytes(b"\x89PNG")
|
||||
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="a.pdf")
|
||||
by_ref = {a.ref: a for a in ir.assets}
|
||||
assert by_ref["images/exists.png"].source is not None
|
||||
assert by_ref["images/missing.png"].source is None
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_refuses_path_traversal_img_path(tmp_path: Path) -> None:
|
||||
"""Untrusted img_path with ``..`` or absolute filesystem segments must
|
||||
not be allowed to point ``AssetSpec.source`` outside ``raw_dir``.
|
||||
|
||||
Otherwise the writer would copy attacker-named files from the host into
|
||||
the sidecar's ``*.blocks.assets/`` directory (file-disclosure path).
|
||||
"""
|
||||
# Place a "secret" file outside the raw bundle that should never be
|
||||
# selectable as an asset source.
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_bytes(b"private")
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "image", "img_path": "../secret.txt"},
|
||||
{"type": "image", "img_path": str(secret)}, # absolute path
|
||||
],
|
||||
)
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="x.pdf")
|
||||
by_ref = {a.ref: a for a in ir.assets}
|
||||
|
||||
# Relative ``..`` escape is rejected outright.
|
||||
assert by_ref["../secret.txt"].source is None
|
||||
|
||||
# Absolute filesystem path is reinterpreted as ``images/<basename>``
|
||||
# inside raw_dir. Since no such file exists, source must remain None
|
||||
# (and crucially must not point at the original secret file).
|
||||
abs_asset = by_ref[str(secret)]
|
||||
assert abs_asset.source is None
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_absolute_url_img_path_resolves_to_images_basename(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When MinerU emits an absolute URL in img_path, the downloader saves
|
||||
it as ``images/<basename>``; the adapter must look there too."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"type": "image",
|
||||
"img_path": "https://cdn.example.com/imgs/figure_42.png",
|
||||
},
|
||||
],
|
||||
)
|
||||
(raw / "images").mkdir()
|
||||
(raw / "images" / "figure_42.png").write_bytes(b"\x89PNGfake")
|
||||
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="u.pdf")
|
||||
asset = ir.assets[0]
|
||||
assert asset.ref == "https://cdn.example.com/imgs/figure_42.png"
|
||||
assert asset.suggested_name == "figure_42.png"
|
||||
assert asset.source is not None
|
||||
assert asset.source.read_bytes() == b"\x89PNGfake"
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_image_url_template_mode_maps_relative_to_images_basename(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When MINERU_IMAGE_URL_TEMPLATE is set, MinerURawClient stores every
|
||||
image reference — including relative ones — at ``images/<basename>``.
|
||||
The adapter must mirror that lookup so the asset is wired up, otherwise
|
||||
the downloaded bytes are silently dropped from the sidecar."""
|
||||
monkeypatch.setenv(
|
||||
"MINERU_IMAGE_URL_TEMPLATE",
|
||||
"http://mineru.internal/assets/{name}",
|
||||
)
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "image", "img_path": "page/img.png"},
|
||||
],
|
||||
)
|
||||
# Downloader's actual landing spot in template mode.
|
||||
(raw / "images").mkdir()
|
||||
(raw / "images" / "img.png").write_bytes(b"\x89PNGtemplate")
|
||||
# The "naive" location (raw_dir/page/img.png) does NOT exist; in
|
||||
# template mode the downloader does not write there.
|
||||
assert not (raw / "page" / "img.png").exists()
|
||||
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="t.pdf")
|
||||
asset = ir.assets[0]
|
||||
assert asset.source is not None
|
||||
assert asset.source.read_bytes() == b"\x89PNGtemplate"
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_adapter_no_template_keeps_relative_path_lookup(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Sanity: without MINERU_IMAGE_URL_TEMPLATE, a relative img_path still
|
||||
resolves under raw_dir at its original location (regression guard for
|
||||
the template-mode change above)."""
|
||||
raw = _write_bundle(
|
||||
tmp_path,
|
||||
[
|
||||
{"type": "image", "img_path": "page/img.png"},
|
||||
],
|
||||
)
|
||||
(raw / "page").mkdir()
|
||||
(raw / "page" / "img.png").write_bytes(b"\x89PNGrel")
|
||||
|
||||
ir = MinerUIRBuilder().normalize_from_workdir(raw, document_name="r.pdf")
|
||||
asset = ir.assets[0]
|
||||
assert asset.source is not None
|
||||
assert asset.source.read_bytes() == b"\x89PNGrel"
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Integration tests for ``parse_mineru`` with the unified sidecar pipeline.
|
||||
|
||||
These tests stub :class:`MinerURawClient.download_into` so no real MinerU
|
||||
service is contacted; the focus is on:
|
||||
|
||||
- happy path: cache miss → download → sidecar emitted with all expected
|
||||
files in the spec-compliant locations
|
||||
- cache hit: a pre-existing valid ``*.mineru_raw/`` + manifest causes
|
||||
``MinerURawClient.download_into`` NOT to be called
|
||||
- ``LIGHTRAG_FORCE_REPARSE_MINERU=true`` forces a re-download even when
|
||||
the manifest is valid
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from lightrag import LightRAG
|
||||
from lightrag.constants import (
|
||||
FULL_DOCS_FORMAT_LIGHTRAG,
|
||||
)
|
||||
from lightrag.parser.external.mineru import compute_size_and_hash
|
||||
from lightrag.parser.external.mineru.cache import current_mineru_options_signature
|
||||
from lightrag.parser.external.mineru.manifest import (
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
write_manifest,
|
||||
)
|
||||
from lightrag.parser.base import ParseContext
|
||||
from lightrag.parser.registry import get_parser
|
||||
from lightrag.utils import EmbeddingFunc, Tokenizer
|
||||
|
||||
|
||||
async def _parse_via_registry(rag, engine, doc_id, file_path, content_data):
|
||||
"""Drive a parser the way the pipeline worker does (registry dispatch)."""
|
||||
result = await get_parser(engine).parse(
|
||||
ParseContext(rag, doc_id, file_path, content_data)
|
||||
)
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
class _SimpleTokenizerImpl:
|
||||
def encode(self, content: str) -> list[int]:
|
||||
return [ord(ch) for ch in content]
|
||||
|
||||
def decode(self, tokens: list[int]) -> str:
|
||||
return "".join(chr(t) for t in tokens)
|
||||
|
||||
|
||||
async def _mock_embedding(texts: list[str]) -> np.ndarray:
|
||||
return np.random.rand(len(texts), 32)
|
||||
|
||||
|
||||
async def _mock_llm(prompt: Any, **kwargs: Any) -> str:
|
||||
return '{"name":"x","summary":"s","detail_description":"d"}'
|
||||
|
||||
|
||||
def _new_rag(tmp_path: Path) -> LightRAG:
|
||||
return LightRAG(
|
||||
working_dir=str(tmp_path),
|
||||
workspace=f"test-mineru-sidecar-{tmp_path.name}",
|
||||
llm_model_func=_mock_llm,
|
||||
embedding_func=EmbeddingFunc(
|
||||
embedding_dim=32,
|
||||
max_token_size=4096,
|
||||
func=_mock_embedding,
|
||||
),
|
||||
tokenizer=Tokenizer("mock-tokenizer", _SimpleTokenizerImpl()),
|
||||
vlm_process_enable=False,
|
||||
)
|
||||
|
||||
|
||||
_FAKE_TABLE_HTML = (
|
||||
'<table><thead><tr><th rowspan="2">Metric</th>'
|
||||
'<th colspan="2">Score</th></tr><tr><th>A</th><th>B</th></tr></thead>'
|
||||
"<tbody><tr><td>Accuracy</td><td>91%</td><td>93%</td></tr></tbody></table>"
|
||||
)
|
||||
|
||||
|
||||
_FAKE_CONTENT_LIST = [
|
||||
{"type": "text", "text": "1 Introduction", "text_level": 1},
|
||||
{"type": "text", "text": "Body paragraph."},
|
||||
{
|
||||
"type": "table",
|
||||
"table_body": _FAKE_TABLE_HTML,
|
||||
"table_caption": ["Tbl"],
|
||||
"page_idx": 0,
|
||||
"bbox": [10, 10, 100, 50],
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"img_path": "images/img_001.jpg",
|
||||
"image_caption": ["Fig 1"],
|
||||
"page_idx": 1,
|
||||
"bbox": [20, 20, 200, 100],
|
||||
},
|
||||
{"type": "equation", "text": "$E = mc^2$", "caption": "Eq 1", "page_idx": 1},
|
||||
]
|
||||
|
||||
|
||||
def _install_fake_download(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]:
|
||||
"""Replace :meth:`MinerURawClient.download_into` with a recorder that
|
||||
writes a synthetic bundle (content_list.json + one image + manifest).
|
||||
"""
|
||||
import lightrag.parser.external.mineru.client as client_mod
|
||||
|
||||
counters = {"calls": 0, "upload_names": []}
|
||||
|
||||
async def _fake_download(
|
||||
self,
|
||||
raw_dir: Path,
|
||||
source_file_path: Path,
|
||||
*,
|
||||
upload_name: str | None = None,
|
||||
):
|
||||
counters["calls"] += 1
|
||||
counters["upload_names"].append(upload_name)
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
(raw_dir / "content_list.json").write_text(
|
||||
json.dumps(_FAKE_CONTENT_LIST, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(raw_dir / "images").mkdir(exist_ok=True)
|
||||
(raw_dir / "images" / "img_001.jpg").write_bytes(b"\xff\xd8\xff\xe0fakeJPEG")
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file_path)
|
||||
crit_size, crit_hash = compute_size_and_hash(raw_dir / "content_list.json")
|
||||
files = [
|
||||
ManifestFile(
|
||||
path="images/img_001.jpg",
|
||||
size=(raw_dir / "images" / "img_001.jpg").stat().st_size,
|
||||
)
|
||||
]
|
||||
manifest = Manifest(
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=upload_name or source_file_path.name,
|
||||
critical_file=ManifestFile(
|
||||
path="content_list.json", size=crit_size, sha256=crit_hash
|
||||
),
|
||||
files=files,
|
||||
total_size_bytes=crit_size + sum(f.size for f in files),
|
||||
task_id=f"fake-{counters['calls']}",
|
||||
api_mode="local",
|
||||
options_signature=current_mineru_options_signature(),
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return manifest
|
||||
|
||||
monkeypatch.setattr(client_mod.MinerURawClient, "download_into", _fake_download)
|
||||
return counters
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_mineru_emits_compliant_sidecar(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""End-to-end: parse_mineru produces *.parsed/ with spec-compliant
|
||||
blocks.jsonl + per-modality JSONs + assets dir; *.mineru_raw/ kept."""
|
||||
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://mineru.example")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
# Don't move the source out from under the cache validator between
|
||||
# repeated parse_mineru calls.
|
||||
async def _noop_archive(_p: str) -> None:
|
||||
return None
|
||||
|
||||
import lightrag.pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"archive_docx_source_after_full_docs_sync",
|
||||
_noop_archive,
|
||||
)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await rag.doc_status.upsert(
|
||||
{
|
||||
doc_id: {
|
||||
"status": "PARSING",
|
||||
"content_summary": "",
|
||||
"content_length": 0,
|
||||
"chunks_count": 0,
|
||||
"chunks_list": [],
|
||||
"created_at": "2026-05-15T00:00:00+00:00",
|
||||
"updated_at": "2026-05-15T00:00:00+00:00",
|
||||
"file_path": "demo.pdf",
|
||||
"track_id": "trk",
|
||||
"content_hash": "",
|
||||
"metadata": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
rag,
|
||||
"_resolve_source_file_for_parser",
|
||||
lambda _p: str(src),
|
||||
)
|
||||
|
||||
parsed = await _parse_via_registry(
|
||||
rag,
|
||||
"mineru",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1, "download_into should run once on miss"
|
||||
|
||||
parsed_dir = Path(parsed["blocks_path"]).parent
|
||||
assert parsed["parse_format"] == FULL_DOCS_FORMAT_LIGHTRAG
|
||||
assert parsed_dir.name == "demo.pdf.parsed"
|
||||
|
||||
# Sidecar files present
|
||||
files = {p.name for p in parsed_dir.iterdir() if p.is_file()}
|
||||
assert "demo.blocks.jsonl" in files
|
||||
assert "demo.tables.json" in files
|
||||
assert "demo.drawings.json" in files
|
||||
assert "demo.equations.json" in files
|
||||
assert (parsed_dir / "demo.blocks.assets").is_dir()
|
||||
assert (parsed_dir / "demo.blocks.assets" / "img_001.jpg").is_file()
|
||||
|
||||
# Content of blocks.jsonl
|
||||
blocks_raw = (parsed_dir / "demo.blocks.jsonl").read_text()
|
||||
lines = blocks_raw.splitlines()
|
||||
meta = json.loads(lines[0])
|
||||
rows = [json.loads(line) for line in lines[1:]]
|
||||
assert meta["parse_engine"] == "mineru"
|
||||
assert meta["table_file"] is True
|
||||
assert meta["drawing_file"] is True
|
||||
assert meta["equation_file"] is True
|
||||
assert meta["asset_dir"] is True
|
||||
assert meta["doc_title"] == "1 Introduction"
|
||||
# bbox_attributes present for mineru (PDF coordinate context)
|
||||
assert meta["bbox_attributes"] == {"origin": "LEFTTOP", "max": 1000}
|
||||
|
||||
# Spec fix: <table> placeholder inline, not <cite>
|
||||
contents = " ".join(row.get("content", "") for row in rows)
|
||||
assert '<table id="tb-' in contents
|
||||
assert 'format="html"' in contents
|
||||
assert 'format="json"' not in contents
|
||||
assert 'rowspan="2"' in contents
|
||||
assert 'colspan="2"' in contents
|
||||
assert "<cite" not in contents
|
||||
|
||||
# bbox positions present on at least one block
|
||||
assert any(
|
||||
p.get("type") == "bbox"
|
||||
for row in rows
|
||||
for p in row.get("positions") or []
|
||||
)
|
||||
|
||||
# Drawing path points inside *.blocks.assets/
|
||||
drawings = json.loads((parsed_dir / "demo.drawings.json").read_text())[
|
||||
"drawings"
|
||||
]
|
||||
(drawing_id, drawing_item) = next(iter(drawings.items()))
|
||||
assert drawing_id.startswith("im-")
|
||||
assert drawing_item["path"] == "demo.blocks.assets/img_001.jpg"
|
||||
assert drawing_item["self_ref"] == "content_list.json#/3"
|
||||
|
||||
# Raw bundle preserved next to sidecar
|
||||
raw_dir = parsed_dir.parent / "demo.pdf.mineru_raw"
|
||||
assert (raw_dir / "_manifest.json").is_file()
|
||||
assert (raw_dir / "content_list.json").is_file()
|
||||
assert (raw_dir / "images" / "img_001.jpg").is_file()
|
||||
|
||||
# No legacy non-spec image field on tables
|
||||
tables = json.loads((parsed_dir / "demo.tables.json").read_text())["tables"]
|
||||
(_, table_item) = next(iter(tables.items()))
|
||||
assert "image" not in table_item
|
||||
assert table_item["format"] == "html"
|
||||
assert table_item["content"] == _FAKE_TABLE_HTML
|
||||
assert table_item["dimension"] == [3, 3]
|
||||
assert 'rowspan="2"' in table_item["content"]
|
||||
assert 'colspan="2"' in table_item["content"]
|
||||
# HTML tables store table_header as the raw <thead> (rowspan/colspan
|
||||
# preserved), not a flattened JSON grid.
|
||||
assert table_item["table_header"] == (
|
||||
'<thead><tr><th rowspan="2">Metric</th>'
|
||||
'<th colspan="2">Score</th></tr>'
|
||||
"<tr><th>A</th><th>B</th></tr></thead>"
|
||||
)
|
||||
assert 'rowspan="2"' in table_item["table_header"]
|
||||
assert 'colspan="2"' in table_item["table_header"]
|
||||
assert table_item["self_ref"] == "content_list.json#/2"
|
||||
|
||||
equations = json.loads((parsed_dir / "demo.equations.json").read_text())[
|
||||
"equations"
|
||||
]
|
||||
(_, equation_item) = next(iter(equations.items()))
|
||||
assert equation_item["self_ref"] == "content_list.json#/4"
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_mineru_cache_hit_skips_download(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A pre-existing valid bundle short-circuits the network call entirely."""
|
||||
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://mineru.example")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
# Don't move the source out from under the cache validator between
|
||||
# repeated parse_mineru calls.
|
||||
async def _noop_archive(_p: str) -> None:
|
||||
return None
|
||||
|
||||
import lightrag.pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"archive_docx_source_after_full_docs_sync",
|
||||
_noop_archive,
|
||||
)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await rag.doc_status.upsert(
|
||||
{
|
||||
doc_id: {
|
||||
"status": "PARSING",
|
||||
"content_summary": "",
|
||||
"content_length": 0,
|
||||
"chunks_count": 0,
|
||||
"chunks_list": [],
|
||||
"created_at": "2026-05-15T00:00:00+00:00",
|
||||
"updated_at": "2026-05-15T00:00:00+00:00",
|
||||
"file_path": "demo.pdf",
|
||||
"track_id": "trk",
|
||||
"content_hash": "",
|
||||
"metadata": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
rag,
|
||||
"_resolve_source_file_for_parser",
|
||||
lambda _p: str(src),
|
||||
)
|
||||
|
||||
# First call: cache miss → download once.
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"mineru",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
# Second call: should hit cache.
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"mineru",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1, "cache hit must not re-download"
|
||||
|
||||
# Third call with force-reparse: cache invalidated.
|
||||
monkeypatch.setenv("LIGHTRAG_FORCE_REPARSE_MINERU", "true")
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"mineru",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_mineru_upload_name_strips_parser_hint(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""MinerU upload name should use the canonical filename, not parser
|
||||
hints embedded in the source basename."""
|
||||
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://mineru.example")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.[mineru-iet].pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await rag.doc_status.upsert(
|
||||
{
|
||||
doc_id: {
|
||||
"status": "PARSING",
|
||||
"content_summary": "",
|
||||
"content_length": 0,
|
||||
"chunks_count": 0,
|
||||
"chunks_list": [],
|
||||
"created_at": "2026-05-15T00:00:00+00:00",
|
||||
"updated_at": "2026-05-15T00:00:00+00:00",
|
||||
"file_path": src.name,
|
||||
"track_id": "trk",
|
||||
"content_hash": "",
|
||||
"metadata": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
rag,
|
||||
"_resolve_source_file_for_parser",
|
||||
lambda _p: str(src),
|
||||
)
|
||||
|
||||
parsed = await _parse_via_registry(
|
||||
rag,
|
||||
"mineru",
|
||||
doc_id=doc_id,
|
||||
file_path=src.name,
|
||||
content_data={},
|
||||
)
|
||||
|
||||
assert counters["upload_names"] == ["demo.pdf"]
|
||||
parsed_dir = Path(parsed["blocks_path"]).parent
|
||||
assert parsed_dir.name == "demo.pdf.parsed"
|
||||
manifest = json.loads(
|
||||
(
|
||||
parsed_dir.parent / "demo.pdf.mineru_raw" / "_manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
assert manifest["source_filename_at_parse"] == "demo.pdf"
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_mineru_cache_invalidates_on_source_change(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Source file content swapped (same/different size) → cache miss."""
|
||||
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://mineru.example")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
# Don't move the source out from under the cache validator between
|
||||
# repeated parse_mineru calls.
|
||||
async def _noop_archive(_p: str) -> None:
|
||||
return None
|
||||
|
||||
import lightrag.pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"archive_docx_source_after_full_docs_sync",
|
||||
_noop_archive,
|
||||
)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await rag.doc_status.upsert(
|
||||
{
|
||||
doc_id: {
|
||||
"status": "PARSING",
|
||||
"content_summary": "",
|
||||
"content_length": 0,
|
||||
"chunks_count": 0,
|
||||
"chunks_list": [],
|
||||
"created_at": "2026-05-15T00:00:00+00:00",
|
||||
"updated_at": "2026-05-15T00:00:00+00:00",
|
||||
"file_path": "demo.pdf",
|
||||
"track_id": "trk",
|
||||
"content_hash": "",
|
||||
"metadata": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rag,
|
||||
"_resolve_source_file_for_parser",
|
||||
lambda _p: str(src),
|
||||
)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"mineru",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
# Same length, different bytes → fast-path passes, hash fails.
|
||||
data = src.read_bytes()
|
||||
src.write_bytes(b"\x00" + data[1:])
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"mineru",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
Vendored
+235
@@ -0,0 +1,235 @@
|
||||
"""Tests for shared helpers in ``lightrag/parser/external/``.
|
||||
|
||||
These cover the pure functions reused across engine integrations:
|
||||
|
||||
- ``compute_size_and_hash`` — single-read (size, hash) pair
|
||||
- ``clear_dir_contents`` — empty a directory while keeping it
|
||||
- ``raw_dir_for_parsed_dir`` — suffix-bound raw dir naming
|
||||
- ``safe_extract_zip`` — refuses path traversal and absolute paths
|
||||
- ``env_bool`` / ``env_int`` / ``env_json`` — env parsing
|
||||
- ``Manifest`` round-trip via ``write_manifest`` / ``load_manifest``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external import (
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
clear_dir_contents,
|
||||
compute_size_and_hash,
|
||||
env_bool,
|
||||
env_int,
|
||||
env_json,
|
||||
load_manifest,
|
||||
raw_dir_for_parsed_dir,
|
||||
safe_extract_zip,
|
||||
write_manifest,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_size_and_hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_compute_size_and_hash_stable(tmp_path: Path) -> None:
|
||||
p = tmp_path / "f.bin"
|
||||
payload = b"hello-external-parser" * 1024
|
||||
p.write_bytes(payload)
|
||||
|
||||
size_a, hash_a = compute_size_and_hash(p)
|
||||
size_b, hash_b = compute_size_and_hash(p)
|
||||
|
||||
assert size_a == len(payload) == size_b
|
||||
assert hash_a == hash_b
|
||||
assert hash_a.startswith("sha256:") and len(hash_a) == len("sha256:") + 64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clear_dir_contents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_dir_contents_keeps_dir_and_removes_children(tmp_path: Path) -> None:
|
||||
d = tmp_path / "raw"
|
||||
d.mkdir()
|
||||
(d / "a.txt").write_text("hi")
|
||||
sub = d / "nested"
|
||||
sub.mkdir()
|
||||
(sub / "b.bin").write_bytes(b"x" * 10)
|
||||
|
||||
clear_dir_contents(d)
|
||||
|
||||
assert d.is_dir()
|
||||
assert list(d.iterdir()) == []
|
||||
|
||||
|
||||
def test_clear_dir_contents_noop_when_missing(tmp_path: Path) -> None:
|
||||
clear_dir_contents(tmp_path / "does-not-exist")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# raw_dir_for_parsed_dir
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_raw_dir_for_parsed_dir_with_suffix(tmp_path: Path) -> None:
|
||||
parsed = tmp_path / "demo.pdf.parsed"
|
||||
raw = raw_dir_for_parsed_dir(parsed, suffix=".docling_raw")
|
||||
assert raw == tmp_path / "demo.pdf.docling_raw"
|
||||
|
||||
|
||||
def test_raw_dir_for_parsed_dir_without_parsed_suffix(tmp_path: Path) -> None:
|
||||
parsed = tmp_path / "other_dir"
|
||||
raw = raw_dir_for_parsed_dir(parsed, suffix=".docling_raw")
|
||||
assert raw == tmp_path / "other_dir.docling_raw"
|
||||
|
||||
|
||||
def test_raw_dir_for_parsed_dir_rejects_bad_suffix(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
raw_dir_for_parsed_dir(tmp_path / "x.parsed", suffix="docling_raw")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# safe_extract_zip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_zip(entries: dict[str, bytes]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, payload in entries.items():
|
||||
zf.writestr(name, payload)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_safe_extract_zip_extracts_flat_bundle(tmp_path: Path) -> None:
|
||||
payload = _make_zip(
|
||||
{
|
||||
"demo.json": b'{"schema_name": "DoclingDocument"}',
|
||||
"demo.md": b"# demo",
|
||||
"artifacts/image_000000.png": b"\x89PNG fake",
|
||||
}
|
||||
)
|
||||
dest = tmp_path / "raw"
|
||||
names = safe_extract_zip(payload, dest)
|
||||
|
||||
assert (dest / "demo.json").read_bytes().startswith(b'{"schema_name"')
|
||||
assert (dest / "demo.md").read_text(encoding="utf-8") == "# demo"
|
||||
assert (dest / "artifacts" / "image_000000.png").is_file()
|
||||
assert sorted(names) == sorted(
|
||||
["demo.json", "demo.md", "artifacts/image_000000.png"]
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_path_traversal(tmp_path: Path) -> None:
|
||||
payload = _make_zip({"../evil.txt": b"oops"})
|
||||
with pytest.raises(RuntimeError, match="unsafe path"):
|
||||
safe_extract_zip(payload, tmp_path / "raw")
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_absolute_path(tmp_path: Path) -> None:
|
||||
payload = _make_zip({"/etc/passwd": b"oops"})
|
||||
with pytest.raises(RuntimeError, match="unsafe path"):
|
||||
safe_extract_zip(payload, tmp_path / "raw")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# env coercion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_env_bool_truthy_falsy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for raw in ("1", "true", "yes", "ON"):
|
||||
monkeypatch.setenv("X", raw)
|
||||
assert env_bool("X", False) is True
|
||||
for raw in ("0", "false", "no", "off"):
|
||||
monkeypatch.setenv("X", raw)
|
||||
assert env_bool("X", True) is False
|
||||
|
||||
|
||||
def test_env_bool_falls_back_on_unrecognized(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("X", "maybe")
|
||||
assert env_bool("X", True) is True
|
||||
assert env_bool("X", False) is False
|
||||
|
||||
|
||||
def test_env_int_falls_back_on_garbage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("X", "not-an-int")
|
||||
assert env_int("X", 7) == 7
|
||||
|
||||
|
||||
def test_env_json_returns_default_on_garbage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("X", "{bad json")
|
||||
assert env_json("X", {"origin": "LEFTBOTTOM"}) == {"origin": "LEFTBOTTOM"}
|
||||
|
||||
|
||||
def test_env_json_parses_object(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("X", '{"a": 1, "b": [2, 3]}')
|
||||
assert env_json("X", None) == {"a": 1, "b": [2, 3]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manifest_round_trip(tmp_path: Path) -> None:
|
||||
raw = tmp_path / "demo.docling_raw"
|
||||
raw.mkdir()
|
||||
crit = ManifestFile(path="demo.json", size=42, sha256="sha256:" + "a" * 64)
|
||||
other = ManifestFile(path="demo.md", size=10)
|
||||
manifest = Manifest(
|
||||
engine="docling",
|
||||
source_content_hash="sha256:" + "b" * 64,
|
||||
source_size_bytes=100,
|
||||
source_filename_at_parse="demo.pdf",
|
||||
critical_file=crit,
|
||||
files=[other],
|
||||
total_size_bytes=52,
|
||||
task_id="task-xyz",
|
||||
endpoint_signature="http://l4ai:5001",
|
||||
engine_version="1.18.0",
|
||||
options_signature="sha256:" + "c" * 64,
|
||||
downloaded_at="2026-05-18T00:00:00Z",
|
||||
extras={"to_formats": ["json", "md"]},
|
||||
)
|
||||
write_manifest(raw, manifest)
|
||||
|
||||
payload = json.loads((raw / "_manifest.json").read_text(encoding="utf-8"))
|
||||
assert payload["engine"] == "docling"
|
||||
assert payload["options_signature"] == "sha256:" + "c" * 64
|
||||
assert payload["extras"] == {"to_formats": ["json", "md"]}
|
||||
|
||||
loaded = load_manifest(raw, expected_engine="docling")
|
||||
assert loaded is not None
|
||||
assert loaded.task_id == "task-xyz"
|
||||
assert loaded.critical_file.size == 42
|
||||
assert loaded.files[0].path == "demo.md"
|
||||
|
||||
|
||||
def test_manifest_load_rejects_wrong_engine(tmp_path: Path) -> None:
|
||||
raw = tmp_path / "demo.docling_raw"
|
||||
raw.mkdir()
|
||||
manifest = Manifest(
|
||||
engine="mineru",
|
||||
source_content_hash="sha256:" + "0" * 64,
|
||||
source_size_bytes=1,
|
||||
source_filename_at_parse="x",
|
||||
critical_file=ManifestFile(path="c", size=1, sha256="sha256:" + "1" * 64),
|
||||
files=[],
|
||||
total_size_bytes=1,
|
||||
)
|
||||
write_manifest(raw, manifest)
|
||||
assert load_manifest(raw, expected_engine="docling") is None
|
||||
|
||||
|
||||
def test_manifest_load_handles_missing_file(tmp_path: Path) -> None:
|
||||
assert load_manifest(tmp_path / "no-such-dir", expected_engine="docling") is None
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
"""Proof that engine params thread identically into both external-parser hooks.
|
||||
|
||||
The ONLY test exercising ``ExternalParserBase.parse``'s decode + thread: the
|
||||
per-file engine params decoded from ``content_data['parse_engine']`` must reach
|
||||
BOTH ``is_bundle_valid`` (cache-hit check) and ``download_into`` (request), or a
|
||||
cache signature could be computed with different params than the bundle was
|
||||
parsed with. Also asserts a malformed stored directive fails the doc loudly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.base import ParseContext
|
||||
from lightrag.parser.external._base import ExternalParserBase
|
||||
|
||||
|
||||
class _Stop(Exception):
|
||||
"""Sentinel to halt parse() right after the hook under test runs."""
|
||||
|
||||
|
||||
def _make_ctx(tmp_path: Path, parse_engine: str) -> ParseContext:
|
||||
source = tmp_path / "doc.pdf"
|
||||
source.write_bytes(b"%PDF-1.4 test")
|
||||
ctx = ParseContext(
|
||||
rag=SimpleNamespace(),
|
||||
doc_id="doc-1",
|
||||
file_path=str(source),
|
||||
content_data={"parse_engine": parse_engine},
|
||||
)
|
||||
# Avoid the pipeline-layer source resolution; hand the template a simple
|
||||
# resolved-source stand-in pointing at our temp file.
|
||||
rs = SimpleNamespace(
|
||||
source_path=source, parsed_dir=tmp_path / "parsed", document_name="doc.pdf"
|
||||
)
|
||||
ctx.resolve = lambda engine_name: rs # type: ignore[assignment]
|
||||
return ctx
|
||||
|
||||
|
||||
class _RecordingParser(ExternalParserBase):
|
||||
engine_name = "mineru" # registered engine that accepts page_range
|
||||
raw_dir_suffix = ".raw"
|
||||
force_reparse_env = "LIGHTRAG_TEST_FORCE_REPARSE"
|
||||
|
||||
def __init__(self, *, hit: bool) -> None:
|
||||
self._hit = hit
|
||||
self.seen: dict[str, object] = {}
|
||||
self.download_called = False
|
||||
|
||||
def is_bundle_valid(self, raw_dir, source_path, *, engine_params=None):
|
||||
self.seen["is_bundle_valid"] = engine_params
|
||||
return self._hit
|
||||
|
||||
async def download_into(
|
||||
self, raw_dir, source_path, *, upload_name, engine_params=None
|
||||
):
|
||||
self.seen["download_into"] = engine_params
|
||||
self.download_called = True
|
||||
raise _Stop()
|
||||
|
||||
def build_ir(self, raw_dir, document_name): # hit path halts here
|
||||
raise _Stop()
|
||||
|
||||
|
||||
def test_miss_path_threads_identical_engine_params(tmp_path):
|
||||
ctx = _make_ctx(tmp_path, "mineru(page_range=1-3)")
|
||||
parser = _RecordingParser(hit=False)
|
||||
with pytest.raises(_Stop):
|
||||
asyncio.run(parser.parse(ctx))
|
||||
assert parser.download_called is True
|
||||
# Both hooks received the SAME decoded params dict.
|
||||
assert parser.seen["is_bundle_valid"] == {"page_range": "1-3"}
|
||||
assert parser.seen["download_into"] == {"page_range": "1-3"}
|
||||
|
||||
|
||||
def test_hit_path_skips_client(tmp_path):
|
||||
ctx = _make_ctx(tmp_path, "mineru(page_range=1-3)")
|
||||
parser = _RecordingParser(hit=True)
|
||||
with pytest.raises(_Stop): # halts in build_ir
|
||||
asyncio.run(parser.parse(ctx))
|
||||
assert parser.download_called is False
|
||||
assert parser.seen["is_bundle_valid"] == {"page_range": "1-3"}
|
||||
|
||||
|
||||
def test_bare_engine_threads_none(tmp_path):
|
||||
ctx = _make_ctx(tmp_path, "mineru")
|
||||
parser = _RecordingParser(hit=False)
|
||||
with pytest.raises(_Stop):
|
||||
asyncio.run(parser.parse(ctx))
|
||||
assert parser.seen["is_bundle_valid"] is None
|
||||
assert parser.seen["download_into"] is None
|
||||
|
||||
|
||||
def test_malformed_stored_parse_engine_fails_loudly(tmp_path):
|
||||
ctx = _make_ctx(tmp_path, "mineru(page_range=") # unbalanced
|
||||
parser = _RecordingParser(hit=False)
|
||||
with pytest.raises(ValueError, match="invalid parse_engine"):
|
||||
asyncio.run(parser.parse(ctx))
|
||||
# Failed before reaching either hook.
|
||||
assert "is_bundle_valid" not in parser.seen
|
||||
Reference in New Issue
Block a user