chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
View File
View File
+222
View File
@@ -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
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+57
View File
@@ -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())
View File
+461
View File
@@ -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"
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
View File
@@ -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())
+235
View File
@@ -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
View File
@@ -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