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
+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())