462 lines
15 KiB
Python
462 lines
15 KiB
Python
"""``*.mineru_raw/`` cache validation tests.
|
|
|
|
Covers every failure mode that triggers a re-download:
|
|
|
|
- missing / malformed manifest
|
|
- source file size mismatch (fast-path)
|
|
- source file content_hash mismatch
|
|
- parser options signature missing / mismatch
|
|
- engine version / endpoint env mismatch
|
|
- critical_file (content_list.json) size or sha256 mismatch
|
|
- any non-critical file size mismatch
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from lightrag.parser.external.mineru import (
|
|
Manifest,
|
|
ManifestFile,
|
|
clear_dir_contents,
|
|
compute_size_and_hash,
|
|
is_bundle_valid,
|
|
raw_dir_for_parsed_dir,
|
|
)
|
|
from lightrag.parser.external.mineru.cache import current_mineru_options_signature
|
|
from lightrag.parser.external.mineru.manifest import write_manifest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def source_file(tmp_path: Path) -> Path:
|
|
p = tmp_path / "src.pdf"
|
|
p.write_bytes(b"Hello PDF" * 100)
|
|
return p
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_bundle(tmp_path: Path, source_file: Path) -> tuple[Path, Manifest]:
|
|
"""Build a fully-valid bundle alongside ``source_file`` and return
|
|
``(raw_dir, manifest)``."""
|
|
raw = tmp_path / "src.mineru_raw"
|
|
raw.mkdir()
|
|
content_list = raw / "content_list.json"
|
|
content_list.write_text('[{"type":"text","text":"hi"}]', encoding="utf-8")
|
|
images = raw / "images"
|
|
images.mkdir()
|
|
(images / "img1.png").write_bytes(b"PNG" * 50)
|
|
(images / "img2.png").write_bytes(b"PNG" * 60)
|
|
|
|
src_size, src_hash = compute_size_and_hash(source_file)
|
|
crit_size, crit_hash = compute_size_and_hash(content_list)
|
|
files = [
|
|
ManifestFile(path="images/img1.png", size=(images / "img1.png").stat().st_size),
|
|
ManifestFile(path="images/img2.png", size=(images / "img2.png").stat().st_size),
|
|
]
|
|
manifest = Manifest(
|
|
source_content_hash=src_hash,
|
|
source_size_bytes=src_size,
|
|
source_filename_at_parse=source_file.name,
|
|
critical_file=ManifestFile(
|
|
path="content_list.json", size=crit_size, sha256=crit_hash
|
|
),
|
|
files=files,
|
|
total_size_bytes=crit_size + sum(f.size for f in files),
|
|
task_id="task-1",
|
|
api_mode="local",
|
|
options_signature=current_mineru_options_signature(),
|
|
)
|
|
write_manifest(raw, manifest)
|
|
return raw, manifest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layout helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_raw_dir_naming(tmp_path: Path) -> None:
|
|
parsed = tmp_path / "report.pdf.parsed"
|
|
raw = raw_dir_for_parsed_dir(parsed)
|
|
assert raw.name == "report.pdf.mineru_raw"
|
|
assert raw.parent == parsed.parent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation: happy path + every individual failure mode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_is_bundle_valid_happy_path(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
assert is_bundle_valid(raw, source_file) is True
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_manifest_missing(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
(raw / "_manifest.json").unlink()
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_manifest_malformed(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
(raw / "_manifest.json").write_text("not json")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_manifest_wrong_engine(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload["engine"] = "docling"
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_source_size_changes(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
# Append bytes to the source file — size diverges from manifest fast-path.
|
|
raw, _ = fresh_bundle
|
|
with source_file.open("ab") as fh:
|
|
fh.write(b"x")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_source_hash_changes_but_size_same(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
"""In-place rewrite that preserves byte size but mutates content. The
|
|
fast-path passes but the full hash check catches it."""
|
|
raw, _ = fresh_bundle
|
|
data = source_file.read_bytes()
|
|
# Flip first byte; keep length identical.
|
|
mutated = bytes([data[0] ^ 0xFF]) + data[1:]
|
|
assert len(mutated) == len(data)
|
|
source_file.write_bytes(mutated)
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_engine_version_mismatch(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload["engine_version"] = "magic-pdf 1.5.4"
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
monkeypatch.setenv("MINERU_ENGINE_VERSION", "magic-pdf 1.6.0")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_engine_version_match_passes(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload["engine_version"] = "magic-pdf 1.5.4"
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
monkeypatch.setenv("MINERU_ENGINE_VERSION", "magic-pdf 1.5.4")
|
|
assert is_bundle_valid(raw, source_file) is True
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_engine_version_skip_when_either_side_blank(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Blank manifest engine_version + non-blank env should NOT invalidate
|
|
(no signal from manifest); same for the reverse."""
|
|
raw, _ = fresh_bundle
|
|
# Manifest engine_version is empty by default.
|
|
monkeypatch.setenv("MINERU_ENGINE_VERSION", "anything")
|
|
assert is_bundle_valid(raw, source_file) is True
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_api_mode_mismatch(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload["api_mode"] = "local"
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
monkeypatch.setenv("MINERU_API_MODE", "official")
|
|
monkeypatch.setenv("MINERU_API_TOKEN", "token")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_options_signature_missing(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload.pop("options_signature", None)
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
@pytest.mark.parametrize(
|
|
("key", "value"),
|
|
[
|
|
("MINERU_LOCAL_BACKEND", "pipeline"),
|
|
("MINERU_LOCAL_PARSE_METHOD", "ocr"),
|
|
("MINERU_LOCAL_IMAGE_ANALYSIS", "true"),
|
|
("MINERU_LOCAL_START_PAGE_ID", "1"),
|
|
],
|
|
)
|
|
def test_invalid_when_local_parser_options_change(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
key: str,
|
|
value: str,
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
monkeypatch.setenv("MINERU_API_MODE", "local")
|
|
monkeypatch.setenv(key, value)
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
@pytest.mark.parametrize(
|
|
("key", "value"),
|
|
[
|
|
("MINERU_MODEL_VERSION", "pipeline"),
|
|
("MINERU_IS_OCR", "true"),
|
|
("MINERU_PAGE_RANGES", "1-5"),
|
|
("MINERU_LANGUAGE", "en"),
|
|
("MINERU_ENABLE_TABLE", "false"),
|
|
("MINERU_ENABLE_FORMULA", "false"),
|
|
],
|
|
)
|
|
def test_invalid_when_official_parser_options_change(
|
|
tmp_path: Path,
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
key: str,
|
|
value: str,
|
|
) -> None:
|
|
"""Symmetric coverage for the official-mode partition of the signature.
|
|
|
|
Build a bundle whose ``options_signature`` reflects the official defaults,
|
|
sanity-check that it validates, then flip ``key`` and assert a cache miss.
|
|
"""
|
|
monkeypatch.setenv("MINERU_API_MODE", "official")
|
|
|
|
raw = tmp_path / "src.mineru_raw"
|
|
raw.mkdir()
|
|
content_list = raw / "content_list.json"
|
|
content_list.write_text('[{"type":"text","text":"hi"}]', encoding="utf-8")
|
|
crit_size, crit_hash = compute_size_and_hash(content_list)
|
|
src_size, src_hash = compute_size_and_hash(source_file)
|
|
manifest = Manifest(
|
|
source_content_hash=src_hash,
|
|
source_size_bytes=src_size,
|
|
source_filename_at_parse=source_file.name,
|
|
critical_file=ManifestFile(
|
|
path="content_list.json", size=crit_size, sha256=crit_hash
|
|
),
|
|
files=[],
|
|
total_size_bytes=crit_size,
|
|
task_id="task-official",
|
|
api_mode="official",
|
|
options_signature=current_mineru_options_signature(),
|
|
)
|
|
write_manifest(raw, manifest)
|
|
|
|
assert is_bundle_valid(raw, source_file) is True
|
|
monkeypatch.setenv(key, value)
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_endpoint_signature_mismatch(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload["api_mode"] = "local"
|
|
payload["endpoint_signature"] = "http://old.example"
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
monkeypatch.setenv("MINERU_API_MODE", "local")
|
|
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://new.example")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_endpoint_signature_uses_mode_specific_endpoint(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload["api_mode"] = "local"
|
|
payload["endpoint_signature"] = "http://old.example"
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
monkeypatch.setenv("MINERU_API_MODE", "local")
|
|
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://new.example")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_endpoint_signature_ignores_trailing_slash(
|
|
fresh_bundle: tuple[Path, Manifest],
|
|
source_file: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
payload = json.loads((raw / "_manifest.json").read_text())
|
|
payload["api_mode"] = "local"
|
|
payload["endpoint_signature"] = "http://old.example"
|
|
(raw / "_manifest.json").write_text(json.dumps(payload))
|
|
monkeypatch.setenv("MINERU_API_MODE", "local")
|
|
monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://old.example/")
|
|
assert is_bundle_valid(raw, source_file) is True
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_critical_file_missing(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
(raw / "content_list.json").unlink()
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_critical_file_size_changes(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
cl = raw / "content_list.json"
|
|
cl.write_text(cl.read_text() + "/* extra */")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_critical_file_hash_changes(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
"""Same size, different bytes. sha256 is the terminal check."""
|
|
raw, _ = fresh_bundle
|
|
cl = raw / "content_list.json"
|
|
data = cl.read_text()
|
|
mutated = data[:-1] + "X" # swap last char; size preserved
|
|
assert len(mutated) == len(data)
|
|
cl.write_text(mutated)
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_aux_file_size_changes(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
p = raw / "images" / "img1.png"
|
|
p.write_bytes(p.read_bytes() + b"corruption")
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_invalid_when_aux_file_missing(
|
|
fresh_bundle: tuple[Path, Manifest], source_file: Path
|
|
) -> None:
|
|
raw, _ = fresh_bundle
|
|
(raw / "images" / "img2.png").unlink()
|
|
assert is_bundle_valid(raw, source_file) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_clear_dir_contents_preserves_directory(tmp_path: Path) -> None:
|
|
d = tmp_path / "raw"
|
|
d.mkdir()
|
|
(d / "a.txt").write_text("a")
|
|
(d / "sub").mkdir()
|
|
(d / "sub" / "b.txt").write_text("b")
|
|
clear_dir_contents(d)
|
|
assert d.exists()
|
|
assert list(d.iterdir()) == []
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_compute_size_and_hash_consistency(tmp_path: Path) -> None:
|
|
"""Both values describe the same byte stream."""
|
|
p = tmp_path / "f.bin"
|
|
payload = b"abc" * 1000
|
|
p.write_bytes(payload)
|
|
size, h = compute_size_and_hash(p)
|
|
assert size == len(payload)
|
|
assert h.startswith("sha256:") and len(h) == len("sha256:") + 64
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_manifest_round_trip_via_disk(tmp_path: Path) -> None:
|
|
"""Write → read recovers all fields."""
|
|
raw = tmp_path / "rt.mineru_raw"
|
|
raw.mkdir()
|
|
m = Manifest(
|
|
source_content_hash="sha256:abc",
|
|
source_size_bytes=10,
|
|
source_filename_at_parse="x.pdf",
|
|
critical_file=ManifestFile(
|
|
path="content_list.json", size=5, sha256="sha256:cl"
|
|
),
|
|
files=[ManifestFile(path="images/i.png", size=3)],
|
|
total_size_bytes=8,
|
|
task_id="t1",
|
|
engine_version="v",
|
|
endpoint_signature="ep",
|
|
options_signature="sha256:opts",
|
|
)
|
|
write_manifest(raw, m)
|
|
from lightrag.parser.external.mineru.manifest import load_manifest
|
|
|
|
loaded = load_manifest(raw)
|
|
assert loaded is not None
|
|
assert loaded.source_content_hash == "sha256:abc"
|
|
assert loaded.critical_file.sha256 == "sha256:cl"
|
|
assert [f.path for f in loaded.files] == ["images/i.png"]
|
|
assert loaded.task_id == "t1"
|
|
assert loaded.options_signature == "sha256:opts"
|