chore: import upstream snapshot with attribution
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
"""Tests for ``lightrag/parser/external/docling/cache.py``.
|
||||
|
||||
Covers the cache-miss conditions enumerated in the module docstring:
|
||||
|
||||
- missing / malformed / wrong-engine manifest
|
||||
- source size or hash mismatch
|
||||
- engine_version / endpoint_signature env mismatch
|
||||
- options_signature env mismatch
|
||||
- critical-file size / sha256 mismatch
|
||||
- non-critical file size mismatch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external import Manifest, ManifestFile, write_manifest
|
||||
from lightrag.parser.external._common import compute_size_and_hash
|
||||
from lightrag.parser.external.docling.cache import (
|
||||
compute_options_signature,
|
||||
is_bundle_valid,
|
||||
snapshot_tunable_env,
|
||||
)
|
||||
from lightrag.parser.external.docling.client import FIXED_CONSTANTS
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_envs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in (
|
||||
"DOCLING_DO_OCR",
|
||||
"DOCLING_FORCE_OCR",
|
||||
"DOCLING_OCR_ENGINE",
|
||||
"DOCLING_OCR_PRESET",
|
||||
"DOCLING_OCR_LANG",
|
||||
"DOCLING_DO_FORMULA_ENRICHMENT",
|
||||
"DOCLING_ENGINE_VERSION",
|
||||
"DOCLING_ENDPOINT",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_file(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "src.pdf"
|
||||
p.write_bytes(b"hello pdf payload" * 64)
|
||||
return p
|
||||
|
||||
|
||||
def test_snapshot_tunable_env_uses_effective_defaults(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
unset = snapshot_tunable_env()
|
||||
|
||||
monkeypatch.setenv("DOCLING_DO_OCR", "true")
|
||||
monkeypatch.setenv("DOCLING_FORCE_OCR", "true")
|
||||
monkeypatch.setenv("DOCLING_OCR_ENGINE", "auto")
|
||||
monkeypatch.setenv("DOCLING_OCR_PRESET", "auto")
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", "")
|
||||
monkeypatch.setenv("DOCLING_DO_FORMULA_ENRICHMENT", "false")
|
||||
|
||||
assert snapshot_tunable_env() == unset
|
||||
|
||||
|
||||
def _build_valid_bundle(
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
*,
|
||||
options_signature: str | None = None,
|
||||
) -> Path:
|
||||
raw_dir = tmp_path / "src.docling_raw"
|
||||
raw_dir.mkdir()
|
||||
main_json = raw_dir / "src.json"
|
||||
main_json.write_text('{"schema_name": "DoclingDocument"}', encoding="utf-8")
|
||||
md = raw_dir / "src.md"
|
||||
md.write_text("# title", encoding="utf-8")
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file)
|
||||
crit_size, crit_hash = compute_size_and_hash(main_json)
|
||||
sig = options_signature
|
||||
if sig is None:
|
||||
sig = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
manifest = Manifest(
|
||||
engine="docling",
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file.name,
|
||||
critical_file=ManifestFile(path="src.json", size=crit_size, sha256=crit_hash),
|
||||
files=[ManifestFile(path="src.md", size=md.stat().st_size)],
|
||||
total_size_bytes=crit_size + md.stat().st_size,
|
||||
task_id="task-1",
|
||||
endpoint_signature="http://docling.test",
|
||||
engine_version="",
|
||||
options_signature=sig,
|
||||
extras={"fixed_constants": dict(FIXED_CONSTANTS)},
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return raw_dir
|
||||
|
||||
|
||||
def test_is_bundle_valid_happy_path(tmp_path: Path, source_file: Path) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
|
||||
|
||||
def test_is_bundle_valid_missing_dir(tmp_path: Path, source_file: Path) -> None:
|
||||
assert is_bundle_valid(tmp_path / "ghost", source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_missing_manifest(tmp_path: Path, source_file: Path) -> None:
|
||||
raw = tmp_path / "src.docling_raw"
|
||||
raw.mkdir()
|
||||
(raw / "src.json").write_text("{}")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_wrong_engine(tmp_path: Path, source_file: Path) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
manifest_path = raw / "_manifest.json"
|
||||
data = manifest_path.read_text(encoding="utf-8")
|
||||
manifest_path.write_text(data.replace('"docling"', '"mineru"'), encoding="utf-8")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_source_size_mismatch(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
source_file.write_bytes(source_file.read_bytes() + b"!")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_source_hash_mismatch(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
# Replace contents with same length but different bytes
|
||||
new = b"Y" * source_file.stat().st_size
|
||||
source_file.write_bytes(new)
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_endpoint_change(
|
||||
tmp_path: Path, source_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://other:5001")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_options_signature_change(
|
||||
tmp_path: Path, source_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
monkeypatch.setenv("DOCLING_FORCE_OCR", "false")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_legacy_bundle_misses_with_overrides(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
# A legacy bundle predating signature recording (empty options_signature)
|
||||
# is leniently accepted with no overrides, but a per-file override
|
||||
# (docling(force_ocr=...)) must force a miss — we cannot prove the bundle
|
||||
# was produced with that override, and silently reusing it would drop the
|
||||
# user's explicit param. Mirrors MinerU, which misses on any absent sig.
|
||||
raw = _build_valid_bundle(tmp_path, source_file, options_signature="")
|
||||
assert is_bundle_valid(raw, source_file) is True
|
||||
assert is_bundle_valid(raw, source_file, overrides={"force_ocr": False}) is False
|
||||
assert is_bundle_valid(raw, source_file, overrides={"force_ocr": True}) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_fixed_constants_code_change(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
# Simulate a code-only change to one of the fixed pipeline constants
|
||||
# (e.g. image_export_mode flipped from "referenced" to "embedded"
|
||||
# between parse time and validation time). The manifest stores both
|
||||
# the stale constants and a signature computed from them; validation
|
||||
# must compare against current FIXED_CONSTANTS and miss, not against
|
||||
# the manifest's own copy (which would always match).
|
||||
stale_constants = {**FIXED_CONSTANTS, "image_export_mode": "embedded"}
|
||||
stale_signature = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=stale_constants,
|
||||
)
|
||||
raw = _build_valid_bundle(tmp_path, source_file, options_signature=stale_signature)
|
||||
# Overwrite the manifest's extras to record the stale constants too —
|
||||
# this is the bug surface: if validation rehydrated from extras, it
|
||||
# would reproduce stale_signature and falsely accept the bundle.
|
||||
import json as _json
|
||||
|
||||
mp = raw / "_manifest.json"
|
||||
data = _json.loads(mp.read_text(encoding="utf-8"))
|
||||
data["extras"] = {"fixed_constants": stale_constants}
|
||||
mp.write_text(_json.dumps(data), encoding="utf-8")
|
||||
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_critical_file_corrupt(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
# Corrupt the JSON: same length, different bytes — defeats size check,
|
||||
# so the sha256 path must catch it.
|
||||
current = (raw / "src.json").read_bytes()
|
||||
(raw / "src.json").write_bytes(b"X" * len(current))
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
|
||||
|
||||
def test_is_bundle_valid_other_file_size_mismatch(
|
||||
tmp_path: Path, source_file: Path
|
||||
) -> None:
|
||||
raw = _build_valid_bundle(tmp_path, source_file)
|
||||
(raw / "src.md").write_text("totally different content here that is longer")
|
||||
assert is_bundle_valid(raw, source_file) is False
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
"""Tests for :class:`DoclingRawClient`.
|
||||
|
||||
Cover the contract guarantees that protect the sidecar pipeline:
|
||||
|
||||
- the fixed pipeline constants (``pipeline=standard`` / ``target_type=zip``
|
||||
/ ``to_formats=[json,md]`` / ``image_export_mode=referenced``) are sent
|
||||
on every upload, regardless of env;
|
||||
- terminal non-success states (``failure`` / ``partial_success`` /
|
||||
``skipped``) abort the run **before** any result download;
|
||||
- ``DOCLING_OCR_LANG`` is omitted when empty so docling-serve falls back
|
||||
to its own default.
|
||||
|
||||
Uses an in-process fake httpx client mirroring ``tests/parser/external/mineru/test_client.py``
|
||||
so we don't trip httpx's sync/async stream guard on multipart uploads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external.docling.client import (
|
||||
CONVERT_PATH,
|
||||
POLL_PATH,
|
||||
RESULT_PATH,
|
||||
DoclingRawClient,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal httpx fake (no MockTransport — avoids the multipart encode path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
text: str = "",
|
||||
content: bytes = b"",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.content = content or text.encode("utf-8")
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self) -> Any:
|
||||
return json.loads(self.text) if self.text else {}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
terminal_status: str,
|
||||
zip_bytes: bytes,
|
||||
task_id: str = "task-abc",
|
||||
submit_status_code: int = 200,
|
||||
submit_text: str | None = None,
|
||||
poll_status_code: int = 200,
|
||||
poll_text: str | None = None,
|
||||
result_status_code: int = 200,
|
||||
result_text: str | None = None,
|
||||
) -> None:
|
||||
self.terminal_status = terminal_status
|
||||
self.zip_bytes = zip_bytes
|
||||
self.task_id = task_id
|
||||
self.submit_status_code = submit_status_code
|
||||
self.submit_text = submit_text
|
||||
self.poll_status_code = poll_status_code
|
||||
self.poll_text = poll_text
|
||||
self.result_status_code = result_status_code
|
||||
self.result_text = result_text
|
||||
|
||||
self.post_calls: list[dict] = []
|
||||
self.get_calls: list[dict] = []
|
||||
self.result_calls = 0
|
||||
|
||||
|
||||
_CURRENT: dict[str, _Recorder] = {}
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *_: Any, **__: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
pass
|
||||
|
||||
async def post(
|
||||
self,
|
||||
url: str,
|
||||
files: Any = None,
|
||||
data: Any = None,
|
||||
json: Any = None,
|
||||
headers: Any = None,
|
||||
) -> _FakeResponse:
|
||||
recorder = _CURRENT["recorder"]
|
||||
# Production passes a file handle inside a `with` block — by the time
|
||||
# tests inspect `post_calls` it's already closed. Drain the stream
|
||||
# here so assertions can keep reading the payload as bytes.
|
||||
snapshot_files = files
|
||||
if files and "files" in files:
|
||||
name, payload, ctype = files["files"]
|
||||
if hasattr(payload, "read"):
|
||||
payload = payload.read()
|
||||
snapshot_files = {"files": (name, payload, ctype)}
|
||||
recorder.post_calls.append(
|
||||
{"url": url, "files": snapshot_files, "data": data, "json": json}
|
||||
)
|
||||
if CONVERT_PATH in url:
|
||||
if recorder.submit_status_code != 200:
|
||||
return _FakeResponse(
|
||||
status_code=recorder.submit_status_code,
|
||||
text=recorder.submit_text or "",
|
||||
)
|
||||
return _FakeResponse(
|
||||
status_code=200,
|
||||
text=json_dump({"task_id": recorder.task_id}),
|
||||
)
|
||||
raise AssertionError(f"unexpected POST {url}")
|
||||
|
||||
async def get(
|
||||
self, url: str, params: Any = None, headers: Any = None
|
||||
) -> _FakeResponse:
|
||||
recorder = _CURRENT["recorder"]
|
||||
recorder.get_calls.append({"url": url, "params": params})
|
||||
# Mirror production: the client encodes the task id into a single path
|
||||
# segment, so route on the encoded form (a no-op for URL-safe ids).
|
||||
encoded = quote(recorder.task_id, safe="")
|
||||
if POLL_PATH.format(task_id=encoded) in url:
|
||||
if recorder.poll_status_code != 200:
|
||||
return _FakeResponse(
|
||||
status_code=recorder.poll_status_code,
|
||||
text=recorder.poll_text or "",
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"task_id": recorder.task_id,
|
||||
"task_status": recorder.terminal_status,
|
||||
}
|
||||
if recorder.terminal_status != "success":
|
||||
payload["error_message"] = "synthetic-failure"
|
||||
return _FakeResponse(status_code=200, text=json_dump(payload))
|
||||
if RESULT_PATH.format(task_id=encoded) in url:
|
||||
recorder.result_calls += 1
|
||||
if recorder.result_status_code != 200:
|
||||
return _FakeResponse(
|
||||
status_code=recorder.result_status_code,
|
||||
text=recorder.result_text or "",
|
||||
)
|
||||
return _FakeResponse(
|
||||
status_code=200,
|
||||
content=recorder.zip_bytes,
|
||||
headers={"content-type": "application/zip"},
|
||||
)
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
|
||||
|
||||
def json_dump(payload: Any) -> str:
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
def _form_pairs(data: Any) -> list[tuple[str, str]]:
|
||||
"""Normalize httpx form data into repeated ``(name, value)`` pairs.
|
||||
|
||||
Production passes a mapping so httpx 0.28 keeps multipart ``files=`` on
|
||||
the async path. List values in that mapping represent repeated form keys.
|
||||
Older tests used tuple lists directly; accepting both keeps assertions
|
||||
focused on the wire contract instead of the container type.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for name, value in data.items():
|
||||
values = value if isinstance(value, list) else [value]
|
||||
pairs.extend((str(name), str(v)) for v in values)
|
||||
return pairs
|
||||
return [(str(name), str(value)) for name, value in data]
|
||||
|
||||
|
||||
def _fake_zip_with_main_json(stem: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(f"{stem}.json", b'{"schema_name": "DoclingDocument"}')
|
||||
zf.writestr(f"{stem}.md", b"# hello")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _install_fake_httpx(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Replace ``httpx.AsyncClient`` and ``httpx.Timeout`` references in
|
||||
the docling client module with no-arg fakes."""
|
||||
monkeypatch.setattr(
|
||||
"lightrag.parser.external.docling.client.httpx.AsyncClient",
|
||||
_FakeAsyncClient,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"lightrag.parser.external.docling.client.httpx.Timeout",
|
||||
lambda *a, **kw: None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_pdf(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "demo.pdf"
|
||||
p.write_bytes(b"%PDF-1.4 fake")
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def docling_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
for name in (
|
||||
"DOCLING_DO_OCR",
|
||||
"DOCLING_FORCE_OCR",
|
||||
"DOCLING_OCR_ENGINE",
|
||||
"DOCLING_OCR_PRESET",
|
||||
"DOCLING_OCR_LANG",
|
||||
"DOCLING_DO_FORMULA_ENRICHMENT",
|
||||
"DOCLING_ENGINE_VERSION",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_docling_client_sends_fixed_constants(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
raw_dir = tmp_path / "demo.docling_raw"
|
||||
manifest = await DoclingRawClient().download_into(raw_dir, source_pdf)
|
||||
|
||||
assert len(recorder.post_calls) == 1
|
||||
data = recorder.post_calls[0]["data"]
|
||||
field_map: dict[str, list[str]] = {}
|
||||
for name, value in _form_pairs(data):
|
||||
field_map.setdefault(name, []).append(value)
|
||||
|
||||
assert field_map["pipeline"] == ["standard"]
|
||||
assert field_map["target_type"] == ["zip"]
|
||||
assert field_map["image_export_mode"] == ["referenced"]
|
||||
assert sorted(field_map["to_formats"]) == ["json", "md"]
|
||||
|
||||
files = recorder.post_calls[0]["files"]
|
||||
assert "files" in files
|
||||
name, blob, ctype = files["files"]
|
||||
assert name == "demo.pdf"
|
||||
assert blob.startswith(b"%PDF-1.4")
|
||||
assert ctype == "application/octet-stream"
|
||||
|
||||
assert manifest.task_id == recorder.task_id
|
||||
assert manifest.engine == "docling"
|
||||
assert manifest.extras["fixed_constants"]["pipeline"] == "standard"
|
||||
assert manifest.endpoint_signature == "http://docling.test"
|
||||
|
||||
|
||||
async def test_docling_client_partial_success_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="partial_success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
msg = str(excinfo.value)
|
||||
assert recorder.task_id in msg
|
||||
assert "partial_success" in msg
|
||||
assert "synthetic-failure" in msg
|
||||
assert recorder.result_calls == 0
|
||||
|
||||
|
||||
async def test_docling_client_failure_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="failure",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
assert recorder.result_calls == 0
|
||||
|
||||
|
||||
async def test_docling_client_skipped_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="skipped",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
assert recorder.result_calls == 0
|
||||
|
||||
|
||||
async def test_docling_client_upload_http_error_preserves_response_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
submit_status_code=400,
|
||||
submit_text=json_dump({"detail": "unsupported file type"}),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling upload for 'demo.pdf'" in message
|
||||
assert "HTTP 400" in message
|
||||
assert "unsupported file type" in message
|
||||
|
||||
|
||||
async def test_docling_client_poll_http_error_preserves_response_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
poll_status_code=503,
|
||||
poll_text=json_dump({"message": "queue unavailable"}),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling task task-abc poll" in message
|
||||
assert "HTTP 503" in message
|
||||
assert "queue unavailable" in message
|
||||
|
||||
|
||||
async def test_docling_client_result_redirect_treated_as_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
# docling-serve fronted by a misconfigured proxy could emit a 302 to a
|
||||
# CDN that httpx (default ``follow_redirects=False``) won't follow.
|
||||
# Without the explicit non-2xx guard the redirect body would fall into
|
||||
# the zip-decoder and surface as a cryptic "bad zip" error.
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
result_status_code=302,
|
||||
result_text="",
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling result task-abc download" in message
|
||||
assert "HTTP 302" in message
|
||||
|
||||
|
||||
async def test_docling_client_result_http_error_preserves_response_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
result_status_code=500,
|
||||
result_text="zip artifact missing",
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await DoclingRawClient().download_into(
|
||||
tmp_path / "demo.docling_raw", source_pdf
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Docling result task-abc download" in message
|
||||
assert "HTTP 500" in message
|
||||
assert "zip artifact missing" in message
|
||||
|
||||
|
||||
async def test_docling_client_encodes_task_id_into_url_path_segment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
# Security regression (CWE-116): the poll/result task id is service-returned.
|
||||
# A crafted value with ``/`` / ``?`` / ``..`` must be percent-encoded into a
|
||||
# single path segment so it cannot escape ``/v1/status/poll/{id}`` or append
|
||||
# a query string to the request the client issues.
|
||||
malicious = "../admin?x=1"
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
task_id=malicious,
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
poll_url = recorder.get_calls[0]["url"]
|
||||
result_url = recorder.get_calls[-1]["url"]
|
||||
for url in (poll_url, result_url):
|
||||
# ``..`` survives (dot is unreserved) but the separators that grant
|
||||
# request-structure control are neutralized.
|
||||
assert "..%2Fadmin%3Fx%3D1" in url
|
||||
assert "/admin" not in url
|
||||
assert "?x=1" not in url
|
||||
|
||||
|
||||
async def test_docling_client_ocr_lang_omitted_when_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
data = recorder.post_calls[0]["data"]
|
||||
names = [name for name, _ in _form_pairs(data)]
|
||||
assert "ocr_lang" not in names
|
||||
|
||||
|
||||
async def test_docling_client_ocr_lang_sent_when_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", '["en","zh"]')
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
data = recorder.post_calls[0]["data"]
|
||||
langs = [v for name, v in _form_pairs(data) if name == "ocr_lang"]
|
||||
assert langs == ["en", "zh"]
|
||||
|
||||
|
||||
async def test_docling_client_ocr_lang_csv_form(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
source_pdf: Path,
|
||||
) -> None:
|
||||
"""CSV fallback when value isn't valid JSON."""
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", "en, fr")
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
data = recorder.post_calls[0]["data"]
|
||||
langs = [v for name, v in _form_pairs(data) if name == "ocr_lang"]
|
||||
assert langs == ["en", "fr"]
|
||||
|
||||
|
||||
async def test_docling_client_rejects_missing_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "")
|
||||
with pytest.raises(ValueError, match="DOCLING_ENDPOINT"):
|
||||
DoclingRawClient()
|
||||
|
||||
|
||||
async def test_docling_client_strips_parser_hint_from_upload_filename(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
# Regression: a hinted source (``report.[docling].pdf``) used to cause
|
||||
# docling-serve to name its bundle JSON ``report.[docling].json``, which
|
||||
# the adapter (looking for ``report.json``) could not locate. The
|
||||
# pipeline now passes the canonical name as ``upload_filename`` so the
|
||||
# bundle is canonical-stem from the start.
|
||||
hinted = tmp_path / "report.[docling].pdf"
|
||||
hinted.write_bytes(b"%PDF-1.4 fake")
|
||||
# The fake zip mimics docling-serve responding with the *canonical* stem,
|
||||
# which is what would happen once we send the canonical filename.
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("report"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
raw_dir = tmp_path / "report.docling_raw"
|
||||
manifest = await DoclingRawClient().download_into(
|
||||
raw_dir, hinted, upload_filename="report.pdf"
|
||||
)
|
||||
|
||||
name, _blob, _ctype = recorder.post_calls[0]["files"]["files"]
|
||||
assert name == "report.pdf"
|
||||
assert manifest.source_filename_at_parse == "report.pdf"
|
||||
assert manifest.critical_file.path == "report.json"
|
||||
assert (raw_dir / "report.json").is_file()
|
||||
|
||||
|
||||
async def test_docling_client_default_upload_filename_falls_back_to_source_name(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, source_pdf: Path
|
||||
) -> None:
|
||||
# Back-compat guard: callers that don't pass ``upload_filename`` (any
|
||||
# path other than the production pipeline) keep the legacy behavior of
|
||||
# using the on-disk source filename.
|
||||
recorder = _Recorder(
|
||||
terminal_status="success",
|
||||
zip_bytes=_fake_zip_with_main_json("demo"),
|
||||
)
|
||||
_CURRENT["recorder"] = recorder
|
||||
_install_fake_httpx(monkeypatch)
|
||||
|
||||
await DoclingRawClient().download_into(tmp_path / "demo.docling_raw", source_pdf)
|
||||
|
||||
name, _blob, _ctype = recorder.post_calls[0]["files"]["files"]
|
||||
assert name == "demo.pdf"
|
||||
+1138
File diff suppressed because it is too large
Load Diff
+57
@@ -0,0 +1,57 @@
|
||||
"""Tests for ``lightrag/parser/external/docling/manifest.py`` helpers.
|
||||
|
||||
Targets the contract guarantees that the rest of the docling flow relies on:
|
||||
``select_main_json`` must find the bundle's main JSON even when ``_manifest.json``
|
||||
sits alongside it, and the preferred-path lookup must take priority over the
|
||||
fallback glob.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.external.docling.manifest import select_main_json
|
||||
|
||||
|
||||
def _touch(path: Path, content: str = "{}") -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_select_main_json_preferred_path_hits(tmp_path: Path) -> None:
|
||||
# Manifest is present (the typical post-download state), but the preferred
|
||||
# ``<stem>.json`` exists, so the fallback glob is not consulted at all.
|
||||
_touch(tmp_path / "report.json")
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
assert select_main_json(tmp_path, Path("report.pdf")) == tmp_path / "report.json"
|
||||
|
||||
|
||||
def test_select_main_json_fallback_ignores_manifest(tmp_path: Path) -> None:
|
||||
# Defensive: when the preferred path misses (e.g. docling-serve renamed
|
||||
# the stem for whatever reason), the fallback glob must NOT confuse
|
||||
# ``_manifest.json`` for a bundle JSON. Pre-fix this case raised
|
||||
# "multiple .json candidates".
|
||||
_touch(tmp_path / "report.json")
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
assert select_main_json(tmp_path, Path("other.pdf")) == tmp_path / "report.json"
|
||||
|
||||
|
||||
def test_select_main_json_raises_when_only_manifest_present(tmp_path: Path) -> None:
|
||||
# If the bundle JSON is genuinely missing, the manifest alone is not a
|
||||
# valid substitute — the helper must still raise rather than silently
|
||||
# returning the manifest.
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
with pytest.raises(RuntimeError, match="contains no .json file"):
|
||||
select_main_json(tmp_path, Path("report.pdf"))
|
||||
|
||||
|
||||
def test_select_main_json_raises_on_real_ambiguity(tmp_path: Path) -> None:
|
||||
# Two genuine bundle JSONs is still an error; the manifest filter must
|
||||
# not mask multi-candidate detection.
|
||||
_touch(tmp_path / "report.json")
|
||||
_touch(tmp_path / "extra.json")
|
||||
_touch(tmp_path / "_manifest.json", '{"engine":"docling"}')
|
||||
with pytest.raises(RuntimeError, match="multiple .json candidates"):
|
||||
select_main_json(tmp_path, Path("other.pdf"))
|
||||
@@ -0,0 +1,668 @@
|
||||
"""Integration tests for ``parse_docling`` with the unified sidecar pipeline.
|
||||
|
||||
Stubs :class:`DoclingRawClient.download_into` so no real docling-serve is
|
||||
contacted; the focus is on:
|
||||
|
||||
- happy path: cache miss → fake bundle written → sidecar emitted with all
|
||||
expected files at the spec-compliant locations
|
||||
- cache hit: a pre-existing valid ``*.docling_raw/`` + manifest causes
|
||||
``DoclingRawClient.download_into`` NOT to be called
|
||||
- ``LIGHTRAG_FORCE_REPARSE_DOCLING=true`` forces a re-download even when
|
||||
the manifest is valid
|
||||
- source content swap → cache miss
|
||||
- options_signature change (``DOCLING_OCR_LANG`` toggle) → cache miss
|
||||
- adapter sees zero blocks → parse fails loudly (no half-baked sidecar)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from lightrag import LightRAG
|
||||
from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG
|
||||
from lightrag.parser.external import (
|
||||
Manifest,
|
||||
ManifestFile,
|
||||
compute_size_and_hash,
|
||||
write_manifest,
|
||||
)
|
||||
from lightrag.parser.external.docling.cache import (
|
||||
compute_options_signature,
|
||||
snapshot_tunable_env,
|
||||
)
|
||||
from lightrag.parser.external.docling.client import FIXED_CONSTANTS
|
||||
from lightrag.parser.base import ParseContext
|
||||
from lightrag.parser.registry import get_parser
|
||||
from lightrag.utils import EmbeddingFunc, Tokenizer
|
||||
|
||||
|
||||
async def _parse_via_registry(rag, engine, doc_id, file_path, content_data):
|
||||
"""Drive a parser the way the pipeline worker does (registry dispatch)."""
|
||||
result = await get_parser(engine).parse(
|
||||
ParseContext(rag, doc_id, file_path, content_data)
|
||||
)
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
class _SimpleTokenizerImpl:
|
||||
def encode(self, content: str) -> list[int]:
|
||||
return [ord(ch) for ch in content]
|
||||
|
||||
def decode(self, tokens: list[int]) -> str:
|
||||
return "".join(chr(t) for t in tokens)
|
||||
|
||||
|
||||
async def _mock_embedding(texts: list[str]) -> np.ndarray:
|
||||
return np.random.rand(len(texts), 32)
|
||||
|
||||
|
||||
async def _mock_llm(prompt: Any, **kwargs: Any) -> str:
|
||||
return '{"name":"x","summary":"s","detail_description":"d"}'
|
||||
|
||||
|
||||
def _new_rag(tmp_path: Path) -> LightRAG:
|
||||
return LightRAG(
|
||||
working_dir=str(tmp_path),
|
||||
workspace=f"test-docling-sidecar-{tmp_path.name}",
|
||||
llm_model_func=_mock_llm,
|
||||
embedding_func=EmbeddingFunc(
|
||||
embedding_dim=32,
|
||||
max_token_size=4096,
|
||||
func=_mock_embedding,
|
||||
),
|
||||
tokenizer=Tokenizer("mock-tokenizer", _SimpleTokenizerImpl()),
|
||||
vlm_process_enable=False,
|
||||
)
|
||||
|
||||
|
||||
_FAKE_DOCLING_JSON = {
|
||||
"schema_name": "DoclingDocument",
|
||||
"version": "1.10.0",
|
||||
"origin": {"filename": "demo.pdf", "mimetype": "application/pdf"},
|
||||
"body": {
|
||||
"self_ref": "#/body",
|
||||
"children": [
|
||||
{"$ref": "#/texts/0"},
|
||||
],
|
||||
"content_layer": "body",
|
||||
"label": "unspecified",
|
||||
},
|
||||
"groups": [],
|
||||
"texts": [
|
||||
{
|
||||
"self_ref": "#/texts/0",
|
||||
"label": "section_header",
|
||||
"text": "Intro",
|
||||
"orig": "Intro",
|
||||
"level": 1,
|
||||
"content_layer": "body",
|
||||
"children": [
|
||||
{"$ref": "#/texts/1"},
|
||||
{"$ref": "#/tables/0"},
|
||||
{"$ref": "#/pictures/0"},
|
||||
{"$ref": "#/texts/2"},
|
||||
],
|
||||
"prov": [
|
||||
{
|
||||
"page_no": 1,
|
||||
"bbox": {
|
||||
"l": 10.0,
|
||||
"t": 100.0,
|
||||
"r": 200.0,
|
||||
"b": 80.0,
|
||||
"coord_origin": "BOTTOMLEFT",
|
||||
},
|
||||
"charspan": [0, 5],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"self_ref": "#/texts/1",
|
||||
"label": "text",
|
||||
"text": "Body paragraph.",
|
||||
"orig": "Body paragraph.",
|
||||
"content_layer": "body",
|
||||
"prov": [
|
||||
{
|
||||
"page_no": 1,
|
||||
"bbox": {
|
||||
"l": 10.0,
|
||||
"t": 60.0,
|
||||
"r": 200.0,
|
||||
"b": 40.0,
|
||||
"coord_origin": "BOTTOMLEFT",
|
||||
},
|
||||
"charspan": [0, 15],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"self_ref": "#/texts/2",
|
||||
"label": "formula",
|
||||
"text": "E = mc^2",
|
||||
"orig": "E = mc^2",
|
||||
"content_layer": "body",
|
||||
"prov": [],
|
||||
},
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"self_ref": "#/tables/0",
|
||||
"label": "table",
|
||||
"content_layer": "body",
|
||||
"data": {
|
||||
"num_rows": 2,
|
||||
"num_cols": 2,
|
||||
"grid": [
|
||||
[{"text": "h1"}, {"text": "h2"}],
|
||||
[{"text": "a"}, {"text": "b"}],
|
||||
],
|
||||
},
|
||||
"prov": [],
|
||||
}
|
||||
],
|
||||
"pictures": [
|
||||
{
|
||||
"self_ref": "#/pictures/0",
|
||||
"label": "picture",
|
||||
"content_layer": "body",
|
||||
"image": {"uri": "artifacts/img_000000.png", "mimetype": "image/png"},
|
||||
"prov": [],
|
||||
}
|
||||
],
|
||||
"key_value_items": [],
|
||||
"form_items": [],
|
||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}, "page_no": 1}},
|
||||
}
|
||||
|
||||
|
||||
def _install_fake_download(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]:
|
||||
"""Replace ``DoclingRawClient.download_into`` with a recorder that
|
||||
writes a synthetic raw bundle and a valid manifest."""
|
||||
import lightrag.parser.external.docling.client as client_mod
|
||||
|
||||
counters = {"calls": 0}
|
||||
|
||||
async def _fake_download(self, raw_dir: Path, source_file_path: Path, **_kwargs):
|
||||
counters["calls"] += 1
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_json = raw_dir / "demo.json"
|
||||
main_json.write_text(json.dumps(_FAKE_DOCLING_JSON), encoding="utf-8")
|
||||
(raw_dir / "demo.md").write_text("# fake md", encoding="utf-8")
|
||||
art = raw_dir / "artifacts"
|
||||
art.mkdir(exist_ok=True)
|
||||
(art / "img_000000.png").write_bytes(b"\x89PNG fake")
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file_path)
|
||||
crit_size, crit_hash = compute_size_and_hash(main_json)
|
||||
others = [
|
||||
ManifestFile(path="demo.md", size=(raw_dir / "demo.md").stat().st_size),
|
||||
ManifestFile(
|
||||
path="artifacts/img_000000.png",
|
||||
size=(art / "img_000000.png").stat().st_size,
|
||||
),
|
||||
]
|
||||
options_signature = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
manifest = Manifest(
|
||||
engine="docling",
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file_path.name,
|
||||
critical_file=ManifestFile(
|
||||
path="demo.json", size=crit_size, sha256=crit_hash
|
||||
),
|
||||
files=others,
|
||||
total_size_bytes=crit_size + sum(f.size for f in others),
|
||||
task_id=f"fake-{counters['calls']}",
|
||||
endpoint_signature="http://docling.test",
|
||||
options_signature=options_signature,
|
||||
extras={"fixed_constants": dict(FIXED_CONSTANTS)},
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return manifest
|
||||
|
||||
monkeypatch.setattr(client_mod.DoclingRawClient, "download_into", _fake_download)
|
||||
return counters
|
||||
|
||||
|
||||
def _stub_pipeline(monkeypatch: pytest.MonkeyPatch, rag: LightRAG, src: Path) -> None:
|
||||
"""Common pipeline-level stubs: avoid moving the source file and pin
|
||||
the file resolver to the synthetic path."""
|
||||
|
||||
async def _noop_archive(_p: str) -> None:
|
||||
return None
|
||||
|
||||
import lightrag.pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"archive_docx_source_after_full_docs_sync",
|
||||
_noop_archive,
|
||||
)
|
||||
monkeypatch.setattr(rag, "_resolve_source_file_for_parser", lambda _p: str(src))
|
||||
|
||||
|
||||
def _seed_doc_status(rag: LightRAG, doc_id: str) -> Any:
|
||||
return rag.doc_status.upsert(
|
||||
{
|
||||
doc_id: {
|
||||
"status": "PARSING",
|
||||
"content_summary": "",
|
||||
"content_length": 0,
|
||||
"chunks_count": 0,
|
||||
"chunks_list": [],
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
"file_path": "demo.pdf",
|
||||
"track_id": "trk",
|
||||
"content_hash": "",
|
||||
"metadata": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_emits_compliant_sidecar(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
parsed = await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
parsed_dir = Path(parsed["blocks_path"]).parent
|
||||
assert parsed["parse_format"] == FULL_DOCS_FORMAT_LIGHTRAG
|
||||
assert parsed_dir.name == "demo.pdf.parsed"
|
||||
|
||||
files = {p.name for p in parsed_dir.iterdir() if p.is_file()}
|
||||
assert "demo.blocks.jsonl" in files
|
||||
assert "demo.tables.json" in files
|
||||
assert "demo.drawings.json" in files
|
||||
assert "demo.equations.json" in files
|
||||
assert (parsed_dir / "demo.blocks.assets").is_dir()
|
||||
assert (parsed_dir / "demo.blocks.assets" / "img_000000.png").is_file()
|
||||
|
||||
blocks_raw = (parsed_dir / "demo.blocks.jsonl").read_text()
|
||||
lines = blocks_raw.splitlines()
|
||||
meta = json.loads(lines[0])
|
||||
rows = [json.loads(line) for line in lines[1:]]
|
||||
assert meta["parse_engine"] == "docling"
|
||||
assert meta["bbox_attributes"] == {"origin": "LEFTBOTTOM"}
|
||||
assert "max" not in meta["bbox_attributes"]
|
||||
assert "page_sizes" not in meta["bbox_attributes"]
|
||||
assert meta["table_file"] is True
|
||||
assert meta["drawing_file"] is True
|
||||
assert meta["equation_file"] is True
|
||||
# No label="title" in the fixture (matches the typical PDF case
|
||||
# where docling produces only section_headers) → doc_title falls
|
||||
# back to the document stem.
|
||||
assert meta["doc_title"] == "demo"
|
||||
|
||||
contents = " ".join(row.get("content", "") for row in rows)
|
||||
assert '<table id="tb-' in contents
|
||||
assert "<drawing" in contents
|
||||
assert "<equation" in contents
|
||||
|
||||
# Raw bundle preserved next to sidecar
|
||||
raw_dir = parsed_dir.parent / "demo.pdf.docling_raw"
|
||||
assert (raw_dir / "_manifest.json").is_file()
|
||||
assert (raw_dir / "demo.json").is_file()
|
||||
assert (raw_dir / "demo.md").is_file()
|
||||
assert (raw_dir / "artifacts" / "img_000000.png").is_file()
|
||||
|
||||
# Drawing path correctly resolved
|
||||
drawings = json.loads((parsed_dir / "demo.drawings.json").read_text())[
|
||||
"drawings"
|
||||
]
|
||||
(drawing_id, drawing_item) = next(iter(drawings.items()))
|
||||
assert drawing_id.startswith("im-")
|
||||
assert drawing_item["path"] == "demo.blocks.assets/img_000000.png"
|
||||
|
||||
# Table self_ref propagated
|
||||
tables = json.loads((parsed_dir / "demo.tables.json").read_text())["tables"]
|
||||
(_, table_item) = next(iter(tables.items()))
|
||||
assert table_item.get("self_ref") == "#/tables/0"
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_cache_hit_skips_download(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1, "cache hit must not re-download"
|
||||
|
||||
monkeypatch.setenv("LIGHTRAG_FORCE_REPARSE_DOCLING", "true")
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_cache_invalidates_on_source_change(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
data = src.read_bytes()
|
||||
src.write_bytes(b"\x00" + data[1:])
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_options_signature_invalidates_cache(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
# Flip an env var that participates in the options signature
|
||||
monkeypatch.setenv("DOCLING_OCR_LANG", "en,zh")
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2, (
|
||||
"DOCLING_OCR_LANG change must invalidate the bundle cache"
|
||||
)
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_endpoint_signature_invalidates_cache(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
counters = _install_fake_download(monkeypatch)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 1
|
||||
|
||||
# Pointing at a different docling-serve instance must not silently
|
||||
# reuse a bundle that was produced by the previous one.
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling-other.test")
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
assert counters["calls"] == 2, (
|
||||
"DOCLING_ENDPOINT change must invalidate the bundle cache"
|
||||
)
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
|
||||
|
||||
@pytest.mark.offline
|
||||
def test_parse_docling_zero_blocks_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When the docling bundle yields no body blocks (e.g. everything was
|
||||
classified as furniture/background) ``parse_docling`` must fail loudly
|
||||
so the document is marked failed — never persist a half-baked sidecar.
|
||||
"""
|
||||
|
||||
async def _run() -> None:
|
||||
monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
|
||||
|
||||
# Install a fake download that writes a valid bundle whose body has
|
||||
# no children — the adapter then produces zero IR blocks.
|
||||
import lightrag.parser.external.docling.client as client_mod
|
||||
|
||||
empty_json: dict[str, Any] = {
|
||||
"schema_name": "DoclingDocument",
|
||||
"version": "1.10.0",
|
||||
"origin": {"filename": "demo.pdf", "mimetype": "application/pdf"},
|
||||
"body": {
|
||||
"self_ref": "#/body",
|
||||
"children": [],
|
||||
"content_layer": "body",
|
||||
"label": "unspecified",
|
||||
},
|
||||
"groups": [],
|
||||
"texts": [],
|
||||
"tables": [],
|
||||
"pictures": [],
|
||||
"key_value_items": [],
|
||||
"form_items": [],
|
||||
"pages": {},
|
||||
}
|
||||
|
||||
async def _fake_download(
|
||||
self, raw_dir: Path, source_file_path: Path, **_kwargs
|
||||
):
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_json = raw_dir / "demo.json"
|
||||
main_json.write_text(json.dumps(empty_json), encoding="utf-8")
|
||||
(raw_dir / "demo.md").write_text("# empty", encoding="utf-8")
|
||||
|
||||
src_size, src_hash = compute_size_and_hash(source_file_path)
|
||||
crit_size, crit_hash = compute_size_and_hash(main_json)
|
||||
others = [
|
||||
ManifestFile(path="demo.md", size=(raw_dir / "demo.md").stat().st_size),
|
||||
]
|
||||
options_signature = compute_options_signature(
|
||||
tunable_env=snapshot_tunable_env(),
|
||||
fixed_constants=FIXED_CONSTANTS,
|
||||
)
|
||||
manifest = Manifest(
|
||||
engine="docling",
|
||||
source_content_hash=src_hash,
|
||||
source_size_bytes=src_size,
|
||||
source_filename_at_parse=source_file_path.name,
|
||||
critical_file=ManifestFile(
|
||||
path="demo.json", size=crit_size, sha256=crit_hash
|
||||
),
|
||||
files=others,
|
||||
total_size_bytes=crit_size + sum(f.size for f in others),
|
||||
task_id="fake-empty",
|
||||
endpoint_signature="http://docling.test",
|
||||
options_signature=options_signature,
|
||||
extras={"fixed_constants": dict(FIXED_CONSTANTS)},
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
write_manifest(raw_dir, manifest)
|
||||
return manifest
|
||||
|
||||
monkeypatch.setattr(
|
||||
client_mod.DoclingRawClient, "download_into", _fake_download
|
||||
)
|
||||
|
||||
input_dir = tmp_path / "inputs" / "ws"
|
||||
input_dir.mkdir(parents=True)
|
||||
src = input_dir / "demo.pdf"
|
||||
src.write_bytes(b"PDFPDF" * 256)
|
||||
|
||||
rag = _new_rag(tmp_path)
|
||||
await rag.initialize_storages()
|
||||
try:
|
||||
_stub_pipeline(monkeypatch, rag, src)
|
||||
doc_id = "doc-abcdef0123456789abcdef0123456789"
|
||||
await _seed_doc_status(rag, doc_id)
|
||||
|
||||
with pytest.raises(ValueError, match="zero blocks"):
|
||||
await _parse_via_registry(
|
||||
rag,
|
||||
"docling",
|
||||
doc_id=doc_id,
|
||||
file_path="demo.pdf",
|
||||
content_data={},
|
||||
)
|
||||
|
||||
# Sidecar must NOT have been emitted: ``write_sidecar`` is reached
|
||||
# only after the zero-blocks check, so no ``*.blocks.jsonl`` may
|
||||
# exist anywhere under the workspace.
|
||||
blocks_files = list(tmp_path.rglob("*.blocks.jsonl"))
|
||||
assert not blocks_files, (
|
||||
f"sidecar emitted despite zero-blocks failure: {blocks_files}"
|
||||
)
|
||||
finally:
|
||||
await rag.finalize_storages()
|
||||
|
||||
asyncio.new_event_loop().run_until_complete(_run())
|
||||
Reference in New Issue
Block a user