chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for the native markdown parser engine (.md / .textpack)."""
|
||||
@@ -0,0 +1,101 @@
|
||||
"""End-to-end: markdown → IRDoc → sidecar files, and re-parse stability.
|
||||
|
||||
Drives the parser's ``extract`` + ``build_ir`` hooks through the real
|
||||
``write_sidecar`` (the same path ``NativeParserBase.parse`` uses) and asserts
|
||||
the produced sidecar shape, then re-runs to confirm byte-stable content blocks
|
||||
(no runtime stamping leaks into the per-block content).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from lightrag.parser.markdown.parser import NativeMarkdownParser
|
||||
from lightrag.sidecar import write_sidecar
|
||||
|
||||
_MD = """# Doc Title
|
||||
|
||||
Intro .
|
||||
|
||||
## Section A
|
||||
|
||||
| Name | Age |
|
||||
|------|-----|
|
||||
| Bob | 30 |
|
||||
|
||||
$$
|
||||
E = mc^2
|
||||
$$
|
||||
|
||||
<table><thead><tr><th>K</th></tr></thead><tbody><tr><td>a</td></tr></tbody></table>
|
||||
"""
|
||||
|
||||
|
||||
def _build_sidecar(tmp_path: Path) -> Path:
|
||||
parser = NativeMarkdownParser()
|
||||
parsed_dir = tmp_path / "doc.md.parsed"
|
||||
asset_dir = parsed_dir / "doc.blocks.assets"
|
||||
parsed_dir.mkdir(parents=True)
|
||||
asset_dir.mkdir()
|
||||
blocks, _, meta = parser._extract_text(_MD, bundle_root=None)
|
||||
ir = parser.build_ir(
|
||||
blocks,
|
||||
document_name="doc.md",
|
||||
asset_dir_name=asset_dir.name,
|
||||
metadata=meta,
|
||||
)
|
||||
write_sidecar(
|
||||
ir,
|
||||
parsed_dir=parsed_dir,
|
||||
doc_id="doc-test",
|
||||
engine="native",
|
||||
clean_parsed_dir=False,
|
||||
block_drawing_path_style=parser.sidecar_path_style,
|
||||
)
|
||||
return parsed_dir
|
||||
|
||||
|
||||
def _content_lines(parsed_dir: Path) -> list[str]:
|
||||
lines = (parsed_dir / "doc.blocks.jsonl").read_text().splitlines()
|
||||
return [line for line in lines if json.loads(line).get("type") == "content"]
|
||||
|
||||
|
||||
def test_sidecar_files_and_payload(tmp_path: Path):
|
||||
parsed_dir = _build_sidecar(tmp_path)
|
||||
assert (parsed_dir / "doc.blocks.jsonl").is_file()
|
||||
assert (parsed_dir / "doc.tables.json").is_file()
|
||||
assert (parsed_dir / "doc.equations.json").is_file()
|
||||
assert (parsed_dir / "doc.drawings.json").is_file()
|
||||
assets = list((parsed_dir / "doc.blocks.assets").iterdir())
|
||||
assert len(assets) == 1 and assets[0].read_bytes().startswith(b"\x89PNG")
|
||||
|
||||
meta_line = json.loads(
|
||||
(parsed_dir / "doc.blocks.jsonl").read_text().splitlines()[0]
|
||||
)
|
||||
assert meta_line["document_format"] == "md"
|
||||
assert meta_line["doc_title"] == "Doc Title"
|
||||
assert meta_line["parse_engine"] == "native"
|
||||
|
||||
tables = json.loads((parsed_dir / "doc.tables.json").read_text())["tables"]
|
||||
fmts = sorted(t["format"] for t in tables.values())
|
||||
assert fmts == ["html", "json"]
|
||||
# Pipe-table header survives as a JSON grid; HTML header as a <thead>.
|
||||
json_table = next(t for t in tables.values() if t["format"] == "json")
|
||||
assert json.loads(json_table["table_header"]) == [["Name", "Age"]]
|
||||
html_table = next(t for t in tables.values() if t["format"] == "html")
|
||||
assert "<thead>" in html_table["table_header"]
|
||||
|
||||
equations = json.loads((parsed_dir / "doc.equations.json").read_text())["equations"]
|
||||
assert any(e["content"] == "E = mc^2" for e in equations.values())
|
||||
|
||||
drawings = json.loads((parsed_dir / "doc.drawings.json").read_text())["drawings"]
|
||||
(drawing,) = drawings.values()
|
||||
assert drawing["path"].startswith("doc.blocks.assets/")
|
||||
assert drawing["format"] == "png"
|
||||
|
||||
|
||||
def test_reparse_content_is_byte_stable(tmp_path: Path):
|
||||
first = _content_lines(_build_sidecar(tmp_path / "a"))
|
||||
second = _content_lines(_build_sidecar(tmp_path / "b"))
|
||||
assert first == second
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Unit tests for ``lightrag.parser.markdown.extract`` (pure extraction).
|
||||
|
||||
Locks in the supported-subset contract: ATX heading splitting, fenced-code
|
||||
suppression, pipe / HTML table recognition with headers, block-level ``$$``
|
||||
math (and inline ``$`` left alone), and inline image resolution via a stubbed
|
||||
resolver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lightrag.parser.markdown.extract import (
|
||||
PREFACE_HEADING,
|
||||
ResolvedImage,
|
||||
extract_markdown,
|
||||
)
|
||||
|
||||
|
||||
class _StubResolver:
|
||||
"""Resolves ``http(s)`` → external link, everything else → local bytes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
def resolve(self, src: str) -> ResolvedImage:
|
||||
self.calls.append(src)
|
||||
if src.startswith(("http://", "https://")):
|
||||
return ResolvedImage(kind="external", url=src, fmt="png")
|
||||
return ResolvedImage(
|
||||
kind="local",
|
||||
asset_ref="sha:" + src,
|
||||
data=b"BYTES:" + src.encode(),
|
||||
suggested_name="img.png",
|
||||
fmt="png",
|
||||
)
|
||||
|
||||
|
||||
def _extract(md: str):
|
||||
return extract_markdown(md, image_resolver=_StubResolver())
|
||||
|
||||
|
||||
def test_headings_split_blocks_and_track_parents():
|
||||
md = "# A\nintro\n## B\nbody\n### C\ndeep\n# D\nlast"
|
||||
ex = _extract(md)
|
||||
summary = [(b["heading"], b["level"], b["parent_headings"]) for b in ex.blocks]
|
||||
assert summary == [
|
||||
("A", 1, []),
|
||||
("B", 2, ["A"]),
|
||||
("C", 3, ["A", "B"]),
|
||||
("D", 1, []),
|
||||
]
|
||||
# Heading line is rendered into the block body (markdown-style).
|
||||
assert ex.blocks[0]["content"].startswith("# A")
|
||||
|
||||
|
||||
def test_content_before_first_heading_is_preface():
|
||||
ex = _extract("loose intro line\n\n# Real")
|
||||
assert ex.blocks[0]["heading"] == PREFACE_HEADING
|
||||
assert ex.blocks[0]["level"] == 0
|
||||
assert "loose intro line" in ex.blocks[0]["content"]
|
||||
|
||||
|
||||
def test_fenced_code_suppresses_all_detection():
|
||||
md = "# H\n```python\n# not a heading\n$$ not eq $$\n| not | table |\n\n```\n"
|
||||
ex = _extract(md)
|
||||
# Only the single real heading produced a block; fence content stays verbatim.
|
||||
assert len(ex.blocks) == 1
|
||||
assert not ex.tables and not ex.equations and not ex.drawings
|
||||
assert "# not a heading" in ex.blocks[0]["content"]
|
||||
assert "$$ not eq $$" in ex.blocks[0]["content"]
|
||||
|
||||
|
||||
def test_tilde_fence_supported():
|
||||
md = "# H\n~~~\n## still code\n~~~\nafter"
|
||||
ex = _extract(md)
|
||||
assert len(ex.blocks) == 1
|
||||
assert "## still code" in ex.blocks[0]["content"]
|
||||
|
||||
|
||||
def test_pipe_table_with_header():
|
||||
md = "| Name | Age |\n|------|-----|\n| Bob | 30 |\n| Sue | 25 |\n"
|
||||
ex = _extract(md)
|
||||
assert len(ex.tables) == 1
|
||||
(table,) = ex.tables.values()
|
||||
assert table["kind"] == "pipe"
|
||||
assert table["rows"] == [["Bob", "30"], ["Sue", "25"]]
|
||||
assert table["header"] == [["Name", "Age"]]
|
||||
|
||||
|
||||
def test_pipe_table_requires_delimiter_row():
|
||||
# A pipe-containing line with no delimiter row underneath is plain text.
|
||||
md = "| just | text |\nnot a delimiter\n"
|
||||
ex = _extract(md)
|
||||
assert not ex.tables
|
||||
|
||||
|
||||
def test_pipe_line_over_thematic_break_is_not_a_table():
|
||||
# A pipe-containing paragraph followed by a bare ``---`` (thematic break /
|
||||
# setext underline) has mismatched column counts (2 vs 1) and so is NOT a
|
||||
# GFM table — it must stay plain text rather than be misrecognised.
|
||||
md = "foo | bar\n---\nnext paragraph\n"
|
||||
ex = _extract(md)
|
||||
assert not ex.tables
|
||||
assert "foo | bar" in ex.blocks[0]["content"]
|
||||
|
||||
|
||||
def test_pipe_table_column_count_must_match_header():
|
||||
# Delimiter row column count differs from the header → not a table.
|
||||
md = "| a | b | c |\n| --- | --- |\n| 1 | 2 | 3 |\n"
|
||||
ex = _extract(md)
|
||||
assert not ex.tables
|
||||
|
||||
|
||||
def test_html_table_captured_verbatim_spanning_lines():
|
||||
md = (
|
||||
"<table>\n"
|
||||
"<thead><tr><th>K</th></tr></thead>\n"
|
||||
"<tbody><tr><td>a</td></tr></tbody>\n"
|
||||
"</table>\n"
|
||||
)
|
||||
ex = _extract(md)
|
||||
assert len(ex.tables) == 1
|
||||
(table,) = ex.tables.values()
|
||||
assert table["kind"] == "html"
|
||||
assert "<thead>" in table["html"] and "</table>" in table["html"]
|
||||
|
||||
|
||||
def test_block_equation_single_and_multiline():
|
||||
md = "$$ E = mc^2 $$\n\ntext\n\n$$\n\\sum x\n$$\n"
|
||||
ex = _extract(md)
|
||||
latexes = list(ex.equations.values())
|
||||
assert "E = mc^2" in latexes
|
||||
assert "\\sum x" in latexes
|
||||
|
||||
|
||||
def test_inline_dollar_not_recognized_as_equation():
|
||||
ex = _extract("cost is $5 and $10 today")
|
||||
assert not ex.equations
|
||||
assert "$5" in ex.blocks[0]["content"]
|
||||
|
||||
|
||||
def test_unclosed_block_equation_is_plain_text():
|
||||
ex = _extract("$$\nE = mc^2\nno closing")
|
||||
assert not ex.equations
|
||||
assert "$$" in ex.blocks[0]["content"]
|
||||
|
||||
|
||||
def test_inline_image_local_and_external_and_dedup():
|
||||
md = " and  and "
|
||||
resolver = _StubResolver()
|
||||
ex = extract_markdown(md, image_resolver=resolver)
|
||||
# Three occurrences → three drawings; same local src deduped to one asset.
|
||||
assert len(ex.drawings) == 3
|
||||
assert len(ex.assets) == 1
|
||||
kinds = sorted(d["kind"] for d in ex.drawings.values())
|
||||
assert kinds == ["external", "local", "local"]
|
||||
# Asset dedup is by ``asset_ref`` in extract (the resolver itself owns any
|
||||
# per-src call caching), so the stub sees one call per occurrence.
|
||||
assert resolver.calls.count("one.png") == 2
|
||||
|
||||
|
||||
def test_base64_src_not_echoed_into_drawing_src():
|
||||
md = ""
|
||||
|
||||
class _B64:
|
||||
def resolve(self, src):
|
||||
return ResolvedImage(
|
||||
kind="local",
|
||||
asset_ref="sha:b64",
|
||||
data=b"ABC",
|
||||
suggested_name="image.png",
|
||||
fmt="png",
|
||||
)
|
||||
|
||||
ex = extract_markdown(md, image_resolver=_B64())
|
||||
(drawing,) = ex.drawings.values()
|
||||
assert drawing["src"] == ""
|
||||
|
||||
|
||||
def test_reference_style_image_not_recognized():
|
||||
# ``![alt][id]`` reference-style is out of the supported subset.
|
||||
ex = _extract("![alt][id]\n\n[id]: real.png")
|
||||
assert not ex.drawings
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for ``NativeMarkdownIRBuilder`` (markers + side tables → IRDoc)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lightrag.parser.markdown.extract import (
|
||||
drawing_marker,
|
||||
equation_marker,
|
||||
table_marker,
|
||||
)
|
||||
from lightrag.parser.markdown.ir_builder import NativeMarkdownIRBuilder
|
||||
|
||||
|
||||
def _normalize(blocks, meta, *, document_name="doc.md"):
|
||||
return NativeMarkdownIRBuilder().normalize(
|
||||
blocks,
|
||||
document_name=document_name,
|
||||
asset_dir_name="doc.blocks.assets",
|
||||
parse_metadata=meta,
|
||||
)
|
||||
|
||||
|
||||
def test_pipe_table_becomes_json_irtable_with_header_grid():
|
||||
blocks = [
|
||||
{
|
||||
"heading": "H",
|
||||
"level": 1,
|
||||
"parent_headings": [],
|
||||
"content": table_marker("t1"),
|
||||
}
|
||||
]
|
||||
meta = {
|
||||
"md_tables": {
|
||||
"t1": {"kind": "pipe", "rows": [["a", "b"]], "header": [["H1", "H2"]]}
|
||||
}
|
||||
}
|
||||
ir = _normalize(blocks, meta)
|
||||
(table,) = ir.blocks[0].tables
|
||||
assert "{{TBL:" in ir.blocks[0].content_template
|
||||
assert table.rows == [["a", "b"]]
|
||||
assert table.html is None
|
||||
assert table.table_header == [["H1", "H2"]]
|
||||
assert (table.num_rows, table.num_cols) == (1, 2)
|
||||
|
||||
|
||||
def test_html_table_becomes_html_irtable_with_thead_and_dims():
|
||||
html = "<table><thead><tr><th>K</th><th>V</th></tr></thead><tbody><tr><td>a</td><td>1</td></tr></tbody></table>"
|
||||
blocks = [
|
||||
{
|
||||
"heading": "H",
|
||||
"level": 1,
|
||||
"parent_headings": [],
|
||||
"content": table_marker("t1"),
|
||||
}
|
||||
]
|
||||
ir = _normalize(blocks, {"md_tables": {"t1": {"kind": "html", "html": html}}})
|
||||
(table,) = ir.blocks[0].tables
|
||||
assert table.rows is None
|
||||
assert table.html and table.html.startswith("<table>")
|
||||
assert isinstance(table.table_header, str) and "<thead>" in table.table_header
|
||||
assert table.body_override and "<thead>" in table.body_override
|
||||
assert (table.num_rows, table.num_cols) == (2, 2)
|
||||
|
||||
|
||||
def test_equation_is_block_level():
|
||||
blocks = [
|
||||
{
|
||||
"heading": "H",
|
||||
"level": 1,
|
||||
"parent_headings": [],
|
||||
"content": equation_marker("e1"),
|
||||
}
|
||||
]
|
||||
ir = _normalize(blocks, {"md_equations": {"e1": "E = mc^2"}})
|
||||
(eq,) = ir.blocks[0].equations
|
||||
assert eq.is_block is True
|
||||
assert eq.latex == "E = mc^2"
|
||||
assert "{{EQ:" in ir.blocks[0].content_template
|
||||
|
||||
|
||||
def test_local_drawing_dedups_assetspec_across_occurrences():
|
||||
content = f"{drawing_marker('d1')} {drawing_marker('d2')}"
|
||||
blocks = [{"heading": "H", "level": 1, "parent_headings": [], "content": content}]
|
||||
meta = {
|
||||
"md_drawings": {
|
||||
"d1": {"kind": "local", "asset_ref": "sha:1", "fmt": "png", "src": "a.png"},
|
||||
"d2": {"kind": "local", "asset_ref": "sha:1", "fmt": "png", "src": "a.png"},
|
||||
},
|
||||
"md_assets": {"sha:1": {"suggested_name": "a.png", "data": b"BYTES"}},
|
||||
}
|
||||
ir = _normalize(blocks, meta)
|
||||
assert len(ir.blocks[0].drawings) == 2
|
||||
assert all(d.asset_ref == "sha:1" for d in ir.blocks[0].drawings)
|
||||
# One shared on-disk asset, carried as bytes for the writer to materialize.
|
||||
assert len(ir.assets) == 1
|
||||
assert ir.assets[0].ref == "sha:1"
|
||||
assert ir.assets[0].source == b"BYTES"
|
||||
|
||||
|
||||
def test_external_drawing_uses_path_override_and_no_asset():
|
||||
blocks = [
|
||||
{
|
||||
"heading": "H",
|
||||
"level": 1,
|
||||
"parent_headings": [],
|
||||
"content": drawing_marker("d1"),
|
||||
}
|
||||
]
|
||||
meta = {
|
||||
"md_drawings": {
|
||||
"d1": {
|
||||
"kind": "external",
|
||||
"url": "http://x/y.png",
|
||||
"fmt": "png",
|
||||
"src": "http://x/y.png",
|
||||
}
|
||||
}
|
||||
}
|
||||
ir = _normalize(blocks, meta)
|
||||
(drawing,) = ir.blocks[0].drawings
|
||||
assert drawing.path_override == "http://x/y.png"
|
||||
assert drawing.asset_ref == ""
|
||||
assert ir.assets == []
|
||||
|
||||
|
||||
def test_doc_title_from_first_h1_else_filename():
|
||||
ir = _normalize(
|
||||
[{"heading": "First", "level": 1, "parent_headings": [], "content": "# First"}],
|
||||
{},
|
||||
)
|
||||
assert ir.doc_title == "First"
|
||||
ir2 = _normalize(
|
||||
[{"heading": "Sub", "level": 2, "parent_headings": [], "content": "## Sub"}],
|
||||
{},
|
||||
document_name="myfile.md",
|
||||
)
|
||||
assert ir2.doc_title == "myfile"
|
||||
|
||||
|
||||
def test_positions_use_heading_anchor():
|
||||
ir = _normalize(
|
||||
[{"heading": "Sec", "level": 2, "parent_headings": [], "content": "## Sec"}],
|
||||
{},
|
||||
)
|
||||
(pos,) = ir.blocks[0].positions
|
||||
assert pos.type == "heading"
|
||||
assert pos.anchor == "Sec"
|
||||
|
||||
|
||||
def test_document_format_from_suffix():
|
||||
ir = _normalize(
|
||||
[{"heading": "X", "level": 1, "parent_headings": [], "content": "# X"}],
|
||||
{},
|
||||
document_name="a.textpack",
|
||||
)
|
||||
assert ir.document_format == "textpack"
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Tests for ``NativeMarkdownParser`` extract/resolver behaviour.
|
||||
|
||||
Covers base64 decoding, ``.textpack`` extraction + file-reference resolution
|
||||
with a traversal guard, zip-bomb rejection, and the SSRF-guarded remote image
|
||||
download (monkeypatched ``urllib``), including the env-controlled failure
|
||||
policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.markdown import parser as md_parser
|
||||
from lightrag.parser.markdown.parser import (
|
||||
NativeMarkdownParser,
|
||||
_host_is_public,
|
||||
_image_bytes_and_ext,
|
||||
_image_ext_from_magic,
|
||||
_looks_like_svg,
|
||||
_pin_socket,
|
||||
)
|
||||
|
||||
# A 1x1 transparent PNG.
|
||||
_PNG_BYTES = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
|
||||
b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05"
|
||||
b"\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
_PNG_B64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
|
||||
def _make_parser() -> NativeMarkdownParser:
|
||||
return NativeMarkdownParser()
|
||||
|
||||
|
||||
_SVG_BYTES = (
|
||||
b'<?xml version="1.0"?>\n'
|
||||
b'<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">'
|
||||
b'<rect width="4" height="4" fill="red"/></svg>'
|
||||
)
|
||||
|
||||
|
||||
def test_magic_detection():
|
||||
assert _image_ext_from_magic(_PNG_BYTES) == "png"
|
||||
assert _image_ext_from_magic(b"\xff\xd8\xff\xe0junk") == "jpg"
|
||||
assert _image_ext_from_magic(b"not an image") is None
|
||||
# SVG is text, not a raster magic — recognised only by the SVG sniffer.
|
||||
assert _image_ext_from_magic(_SVG_BYTES) is None
|
||||
|
||||
|
||||
def test_looks_like_svg():
|
||||
assert _looks_like_svg(_SVG_BYTES) is True
|
||||
assert _looks_like_svg(b"\xef\xbb\xbf \n<svg></svg>") is True # BOM + space
|
||||
assert _looks_like_svg(_PNG_BYTES) is False
|
||||
assert _looks_like_svg(b"<html><body>no svg</body></html>") is False
|
||||
|
||||
|
||||
def test_svg_rasterized_to_png_via_coerce():
|
||||
coerced = _image_bytes_and_ext(
|
||||
_SVG_BYTES, max_bytes=25 * 1024 * 1024, max_svg_pixels=16_000_000
|
||||
)
|
||||
assert coerced is not None
|
||||
data, ext = coerced
|
||||
assert ext == "png"
|
||||
assert data.startswith(b"\x89PNG")
|
||||
|
||||
|
||||
def test_svg_coerce_skips_when_rasterization_fails(monkeypatch):
|
||||
# cairosvg unavailable / render failure -> coerce returns None (caller skips).
|
||||
monkeypatch.setattr(md_parser, "_rasterize_svg", lambda raw, **kw: None)
|
||||
assert (
|
||||
_image_bytes_and_ext(_SVG_BYTES, max_bytes=25 * 1024 * 1024, max_svg_pixels=1)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_svg_coerce_skips_when_rasterized_png_too_large(monkeypatch):
|
||||
monkeypatch.setattr(md_parser, "_rasterize_svg", lambda raw, **kw: _PNG_BYTES)
|
||||
assert (
|
||||
_image_bytes_and_ext(_SVG_BYTES, max_bytes=4, max_svg_pixels=16_000_000) is None
|
||||
)
|
||||
|
||||
|
||||
def test_svg_dimensions_parsed_from_width_height_and_viewbox():
|
||||
assert md_parser._svg_pixel_dimensions(_SVG_BYTES) == (4, 4)
|
||||
# Unit conversion: 1in = 96px, 2in = 192px.
|
||||
svg_in = b'<svg xmlns="http://www.w3.org/2000/svg" width="1in" height="2in"/>'
|
||||
assert md_parser._svg_pixel_dimensions(svg_in) == (96, 192)
|
||||
# width/height missing or relative (%) -> fall back to viewBox w/h.
|
||||
svg_vb = (
|
||||
b'<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 30 20"/>'
|
||||
)
|
||||
assert md_parser._svg_pixel_dimensions(svg_vb) == (30, 20)
|
||||
# Neither resolvable -> None (skipped by the rasterizer).
|
||||
svg_none = b'<svg xmlns="http://www.w3.org/2000/svg"/>'
|
||||
assert md_parser._svg_pixel_dimensions(svg_none) is None
|
||||
|
||||
|
||||
def test_svg_oversized_canvas_rejected_before_render(monkeypatch):
|
||||
# A tiny SVG declaring a huge canvas is rejected on the pre-render pixel
|
||||
# budget — cairosvg.svg2png must never be reached.
|
||||
import cairosvg
|
||||
|
||||
def _boom(*a, **k): # pragma: no cover - must not be called
|
||||
raise AssertionError("cairosvg.svg2png should not run for oversized canvas")
|
||||
|
||||
monkeypatch.setattr(cairosvg, "svg2png", _boom)
|
||||
big = b'<svg xmlns="http://www.w3.org/2000/svg" width="100000" height="100000"/>'
|
||||
assert md_parser._rasterize_svg(big, max_pixels=16_000_000) is None
|
||||
|
||||
|
||||
def test_base64_svg_decoded_and_rasterized():
|
||||
import base64 as _b64
|
||||
|
||||
p = _make_parser()
|
||||
b64 = _b64.b64encode(_SVG_BYTES).decode()
|
||||
md = f"# H\n\n\n"
|
||||
_, warnings, meta = p._extract_text(md, bundle_root=None)
|
||||
(asset,) = meta["md_assets"].values()
|
||||
assert asset["fmt"] == "png"
|
||||
assert asset["data"].startswith(b"\x89PNG")
|
||||
(drawing,) = meta["md_drawings"].values()
|
||||
assert drawing["kind"] == "local"
|
||||
assert not warnings
|
||||
|
||||
|
||||
def test_textpack_svg_file_rasterized_to_png(tmp_path: Path):
|
||||
pack = tmp_path / "note.textpack"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("text.markdown", "# N\n\n\n")
|
||||
zf.writestr("assets/logo.svg", _SVG_BYTES)
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p.extract(
|
||||
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
|
||||
)
|
||||
(asset,) = meta["md_assets"].values()
|
||||
assert asset["fmt"] == "png"
|
||||
assert asset["data"].startswith(b"\x89PNG")
|
||||
# The on-disk name takes the rasterized extension.
|
||||
assert asset["suggested_name"] == "logo.png"
|
||||
assert not warnings
|
||||
|
||||
|
||||
def test_host_is_public_rejects_internal(monkeypatch):
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
|
||||
assert _host_is_public("8.8.8.8") is True
|
||||
assert _host_is_public("127.0.0.1") is False
|
||||
assert _host_is_public("10.0.0.1") is False
|
||||
assert _host_is_public("169.254.0.1") is False
|
||||
assert _host_is_public("::1") is False
|
||||
# CGNAT 100.64.0.0/10 is not private/reserved in Python's flags but is not
|
||||
# globally routable — default-deny via is_global must still reject it.
|
||||
assert _host_is_public("100.64.0.1") is False
|
||||
# TEST-NET (192.0.2.0/24) is likewise non-global.
|
||||
assert _host_is_public("192.0.2.1") is False
|
||||
|
||||
|
||||
def test_allowlist_permits_configured_non_public(monkeypatch):
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", "100.64.0.0/10")
|
||||
assert _host_is_public("100.64.0.1") is True
|
||||
# An address outside the allowlist is still rejected.
|
||||
assert _host_is_public("10.0.0.1") is False
|
||||
|
||||
|
||||
def test_pin_socket_refuses_internal_host(monkeypatch):
|
||||
# The connection pins to the same resolution that authorised the host, so
|
||||
# an internal target cannot be reached even if it would resolve.
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
|
||||
with pytest.raises(OSError, match="non-public host"):
|
||||
_pin_socket("127.0.0.1", 80, None, None)
|
||||
|
||||
|
||||
def test_redirect_to_non_public_host_blocked(monkeypatch):
|
||||
import email.message
|
||||
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
|
||||
handler = md_parser._GuardedRedirectHandler()
|
||||
req = md_parser.urllib.request.Request("http://example.com/a")
|
||||
with pytest.raises(md_parser.urllib.error.HTTPError):
|
||||
handler.redirect_request(
|
||||
req, None, 302, "Found", email.message.Message(), "http://10.0.0.1/evil"
|
||||
)
|
||||
|
||||
|
||||
def test_guarded_https_handler_open_does_not_raise_attribute_error(monkeypatch):
|
||||
# Regression: ``https_open`` must forward only the kwargs the stdlib handler
|
||||
# actually has on this Python version. On 3.12 ``HTTPSHandler`` has no
|
||||
# ``_check_hostname`` attribute, so a hard reference raised AttributeError
|
||||
# and every real download silently fell back to an external link.
|
||||
handler = md_parser._GuardedHTTPSHandler()
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_do_open(conn_cls, req, **kwargs):
|
||||
captured["conn_cls"] = conn_cls
|
||||
captured["kwargs"] = kwargs
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(handler, "do_open", _fake_do_open)
|
||||
req = md_parser.urllib.request.Request("https://example.com/x.png")
|
||||
assert handler.https_open(req) == "ok"
|
||||
assert captured["conn_cls"] is md_parser._GuardedHTTPSConnection
|
||||
assert "context" in captured["kwargs"]
|
||||
|
||||
|
||||
def test_build_guarded_opener_constructs_with_guarded_handlers():
|
||||
opener = md_parser._build_guarded_opener()
|
||||
handler_types = {type(h) for h in opener.handlers}
|
||||
assert md_parser._GuardedHTTPSHandler in handler_types
|
||||
assert md_parser._GuardedHTTPHandler in handler_types
|
||||
|
||||
|
||||
def test_base64_image_decoded_to_asset():
|
||||
p = _make_parser()
|
||||
md = f"# H\n\n\n"
|
||||
blocks, warnings, meta = p._extract_text(md, bundle_root=None)
|
||||
assert len(meta["md_assets"]) == 1
|
||||
(asset,) = meta["md_assets"].values()
|
||||
assert asset["data"].startswith(b"\x89PNG")
|
||||
assert asset["fmt"] == "png"
|
||||
(drawing,) = meta["md_drawings"].values()
|
||||
assert drawing["kind"] == "local"
|
||||
assert not warnings
|
||||
|
||||
|
||||
def test_base64_non_image_bytes_rejected():
|
||||
# ``data:image/png;base64,QUJD`` decodes to b"ABC" — declared PNG but not a
|
||||
# real image. Magic bytes are authoritative, so it must be skipped.
|
||||
p = _make_parser()
|
||||
md = "# H\n\n\n"
|
||||
_, warnings, meta = p._extract_text(md, bundle_root=None)
|
||||
assert meta["md_drawings"] == {}
|
||||
assert meta["md_assets"] == {}
|
||||
assert warnings.get("images_skipped") == 1
|
||||
|
||||
|
||||
def test_standalone_md_relative_image_is_skipped():
|
||||
p = _make_parser()
|
||||
blocks, warnings, meta = p._extract_text(
|
||||
"# H\n\n\n", bundle_root=None
|
||||
)
|
||||
assert meta["md_drawings"] == {}
|
||||
assert warnings.get("images_skipped") == 1
|
||||
|
||||
|
||||
def _write_textpack(path: Path, *, text: str, assets: dict[str, bytes]) -> None:
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("text.markdown", text)
|
||||
for name, data in assets.items():
|
||||
zf.writestr(f"assets/{name}", data)
|
||||
|
||||
|
||||
def test_textpack_file_reference_resolved(tmp_path: Path):
|
||||
pack = tmp_path / "note.textpack"
|
||||
_write_textpack(
|
||||
pack,
|
||||
text="# Note\n\n\n",
|
||||
assets={"pic.png": _PNG_BYTES},
|
||||
)
|
||||
p = _make_parser()
|
||||
blocks, warnings, meta = p.extract(
|
||||
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
|
||||
)
|
||||
assert len(meta["md_assets"]) == 1
|
||||
(asset,) = meta["md_assets"].values()
|
||||
assert asset["data"] == _PNG_BYTES
|
||||
assert asset["suggested_name"] == "pic.png"
|
||||
assert not warnings
|
||||
|
||||
|
||||
def test_textpack_image_outside_assets_dir_is_resolved(tmp_path: Path):
|
||||
# Compatibility: a reference may point anywhere under the bundle root, not
|
||||
# only ``assets/`` — here ``media/pic.png`` and a root-level ``cover.png``.
|
||||
pack = tmp_path / "note.textpack"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("text.markdown", "# N\n\n\n\n\n")
|
||||
zf.writestr("media/pic.png", _PNG_BYTES)
|
||||
zf.writestr("cover.png", _PNG_BYTES)
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p.extract(
|
||||
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
|
||||
)
|
||||
assert len(meta["md_drawings"]) == 2
|
||||
assert all(d["kind"] == "local" for d in meta["md_drawings"].values())
|
||||
# Same bytes → deduped to a single asset.
|
||||
assert len(meta["md_assets"]) == 1
|
||||
assert not warnings
|
||||
|
||||
|
||||
def test_textpack_non_image_file_reference_is_skipped(tmp_path: Path):
|
||||
pack = tmp_path / "note.textpack"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("text.markdown", "# N\n\n\n")
|
||||
zf.writestr("assets/fake.png", b"this is not an image")
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p.extract(
|
||||
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
|
||||
)
|
||||
assert meta["md_drawings"] == {}
|
||||
assert warnings.get("images_skipped") == 1
|
||||
|
||||
|
||||
def test_textpack_oversized_image_is_skipped(tmp_path: Path, monkeypatch):
|
||||
# A bundled asset larger than NATIVE_MD_IMAGE_MAX_BYTES is skipped before it
|
||||
# is read into memory (the per-file cap, separate from the zip-bomb total).
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_MAX_BYTES", "16")
|
||||
pack = tmp_path / "big.textpack"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("text.markdown", "# N\n\n\n")
|
||||
zf.writestr("assets/big.png", _PNG_BYTES) # well over 16 bytes
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p.extract(
|
||||
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="big"
|
||||
)
|
||||
assert meta["md_drawings"] == {}
|
||||
assert meta["md_assets"] == {}
|
||||
assert warnings.get("images_skipped") == 1
|
||||
|
||||
|
||||
def test_textpack_traversal_reference_is_skipped(tmp_path: Path):
|
||||
pack = tmp_path / "evil.textpack"
|
||||
_write_textpack(
|
||||
pack,
|
||||
text="# Evil\n\n\n",
|
||||
assets={"pic.png": _PNG_BYTES},
|
||||
)
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p.extract(
|
||||
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="evil"
|
||||
)
|
||||
assert meta["md_drawings"] == {}
|
||||
assert warnings.get("images_skipped") == 1
|
||||
|
||||
|
||||
def test_textpack_zip_bomb_entry_count_rejected(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setattr(md_parser, "_TEXTPACK_MAX_ENTRIES", 3)
|
||||
pack = tmp_path / "bomb.textpack"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("text.markdown", "# H\n")
|
||||
for i in range(10):
|
||||
zf.writestr(f"assets/f{i}.bin", b"x")
|
||||
p = _make_parser()
|
||||
with pytest.raises(RuntimeError, match="entries"):
|
||||
p.extract(pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="bomb")
|
||||
|
||||
|
||||
# --- remote download (monkeypatched urllib) --------------------------------
|
||||
|
||||
|
||||
class _FakeHeaders:
|
||||
def __init__(self, content_type: str) -> None:
|
||||
self._ct = content_type
|
||||
|
||||
def get_content_type(self) -> str:
|
||||
return self._ct
|
||||
|
||||
|
||||
class _FakeResponse(io.BytesIO):
|
||||
def __init__(self, data: bytes, content_type: str) -> None:
|
||||
super().__init__(data)
|
||||
self.headers = _FakeHeaders(content_type)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
|
||||
def _patch_download(
|
||||
monkeypatch, *, data=_PNG_BYTES, content_type="image/png", host_public=True
|
||||
):
|
||||
monkeypatch.setattr(md_parser, "_host_is_public", lambda host: host_public)
|
||||
|
||||
class _Opener:
|
||||
def open(self, req, timeout=None):
|
||||
return _FakeResponse(data, content_type)
|
||||
|
||||
monkeypatch.setattr(
|
||||
md_parser.urllib.request, "build_opener", lambda *a, **k: _Opener()
|
||||
)
|
||||
|
||||
|
||||
def test_remote_image_dropped_when_download_disabled(monkeypatch):
|
||||
# With downloading explicitly disabled an external image is dropped entirely
|
||||
# — no drawing, no asset — so a doc whose only image is an external link
|
||||
# produces no drawings.json. (Downloading is ON by default.)
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "false")
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p._extract_text(
|
||||
"# H\n\n\n", bundle_root=None
|
||||
)
|
||||
assert meta["md_drawings"] == {}
|
||||
assert meta["md_assets"] == {}
|
||||
assert warnings.get("images_external_dropped") == 1
|
||||
|
||||
|
||||
def test_remote_image_downloaded_when_enabled(monkeypatch):
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
|
||||
_patch_download(monkeypatch)
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p._extract_text(
|
||||
"# H\n\n\n", bundle_root=None
|
||||
)
|
||||
(drawing,) = meta["md_drawings"].values()
|
||||
assert drawing["kind"] == "local"
|
||||
(asset,) = meta["md_assets"].values()
|
||||
assert asset["data"] == _PNG_BYTES
|
||||
|
||||
|
||||
def test_remote_image_failure_kept_external_by_default(monkeypatch):
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_DOWNLOAD_REQUIRED", raising=False)
|
||||
_patch_download(monkeypatch, content_type="text/html") # explicit non-image
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p._extract_text(
|
||||
"# H\n\n\n", bundle_root=None
|
||||
)
|
||||
(drawing,) = meta["md_drawings"].values()
|
||||
assert drawing["kind"] == "external"
|
||||
assert warnings.get("images_download_failed") == 1
|
||||
|
||||
|
||||
def test_remote_image_failure_raises_when_required(monkeypatch):
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_REQUIRED", "true")
|
||||
_patch_download(monkeypatch, content_type="text/html")
|
||||
p = _make_parser()
|
||||
with pytest.raises(Exception):
|
||||
p._extract_text("# H\n\n\n", bundle_root=None)
|
||||
|
||||
|
||||
def test_remote_image_private_host_blocked(monkeypatch):
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_DOWNLOAD_REQUIRED", raising=False)
|
||||
_patch_download(monkeypatch, host_public=False)
|
||||
p = _make_parser()
|
||||
_, warnings, meta = p._extract_text(
|
||||
"# H\n\n\n", bundle_root=None
|
||||
)
|
||||
(drawing,) = meta["md_drawings"].values()
|
||||
assert drawing["kind"] == "external"
|
||||
assert warnings.get("images_download_failed") == 1
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tests for the native-markdown downloaded-image cache (`.native_raw/`).
|
||||
|
||||
The cache lets a re-parse of an unchanged file reuse already-downloaded images
|
||||
instead of hitting the network. ``_download`` is monkeypatched so a cache hit is
|
||||
proven by it NOT being called (it raises) while assets still materialize.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.parser.markdown import parser as md_parser
|
||||
from lightrag.parser.markdown.parser import NativeMarkdownParser
|
||||
|
||||
# A 1x1 transparent PNG.
|
||||
_PNG_BYTES = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
|
||||
b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05"
|
||||
b"\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
_URL = "http://host/y.png"
|
||||
_MD = f"# H\n\n\n"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_download(monkeypatch):
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
|
||||
monkeypatch.delenv("LIGHTRAG_FORCE_REPARSE_NATIVE", raising=False)
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_MAX_BYTES", raising=False)
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_MAX_SVG_PIXELS", raising=False)
|
||||
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
|
||||
|
||||
|
||||
def _patch_download(monkeypatch, payload=(_PNG_BYTES, "png")):
|
||||
"""Patch ``_download`` to count calls and return fixed bytes."""
|
||||
counter = {"n": 0}
|
||||
|
||||
def _fake(self, src):
|
||||
counter["n"] += 1
|
||||
return payload
|
||||
|
||||
monkeypatch.setattr(md_parser._MarkdownImageResolver, "_download", _fake)
|
||||
return counter
|
||||
|
||||
|
||||
def _forbid_download(monkeypatch):
|
||||
"""Patch ``_download`` to fail the test if the network is touched."""
|
||||
|
||||
def _boom(self, src): # pragma: no cover - must not be called on a cache hit
|
||||
raise AssertionError("network download must not run on a cache hit")
|
||||
|
||||
monkeypatch.setattr(md_parser._MarkdownImageResolver, "_download", _boom)
|
||||
|
||||
|
||||
def _make_doc(tmp_path: Path, text: str = _MD) -> tuple[Path, Path]:
|
||||
src = tmp_path / "doc.md"
|
||||
src.write_text(text)
|
||||
parsed = tmp_path / "__parsed__" / "doc.md.parsed"
|
||||
parsed.mkdir(parents=True)
|
||||
(parsed / "doc.blocks.assets").mkdir()
|
||||
return src, parsed
|
||||
|
||||
|
||||
def _extract(p: NativeMarkdownParser, src: Path, parsed: Path):
|
||||
return p.extract(
|
||||
src, parsed_dir=parsed, asset_dir=parsed / "doc.blocks.assets", base_name="doc"
|
||||
)
|
||||
|
||||
|
||||
def _wipe_parsed(parsed: Path) -> None:
|
||||
"""Mirror NativeParserBase.parse's rmtree(parsed_dir) before a re-extract."""
|
||||
shutil.rmtree(parsed)
|
||||
parsed.mkdir(parents=True)
|
||||
(parsed / "doc.blocks.assets").mkdir()
|
||||
|
||||
|
||||
def _raw_dir(tmp_path: Path) -> Path:
|
||||
return tmp_path / "__parsed__" / "doc.md.native_raw"
|
||||
|
||||
|
||||
def test_first_parse_downloads_and_writes_bundle(tmp_path, monkeypatch):
|
||||
counter = _patch_download(monkeypatch)
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_, _, meta = _extract(NativeMarkdownParser(), src, parsed)
|
||||
assert counter["n"] == 1
|
||||
assert len(meta["md_assets"]) == 1
|
||||
raw = _raw_dir(tmp_path)
|
||||
assert (raw / "_manifest.json").is_file()
|
||||
# Exactly one cached image file alongside the manifest.
|
||||
files = sorted(c.name for c in raw.iterdir())
|
||||
assert len(files) == 2 and "_manifest.json" in files
|
||||
|
||||
|
||||
def test_reparse_unchanged_source_is_cache_hit(tmp_path, monkeypatch):
|
||||
_patch_download(monkeypatch)
|
||||
p = NativeMarkdownParser()
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_extract(p, src, parsed) # populate cache
|
||||
_wipe_parsed(parsed) # base parser would rmtree the .parsed dir
|
||||
|
||||
_forbid_download(monkeypatch) # any download now fails the test
|
||||
_, warnings, meta = _extract(p, src, parsed)
|
||||
assert warnings.get("images_cache_hit") == 1
|
||||
assert len(meta["md_assets"]) == 1
|
||||
(asset,) = meta["md_assets"].values()
|
||||
assert asset["data"] == _PNG_BYTES
|
||||
|
||||
|
||||
def test_pure_cache_hit_does_not_touch_bundle(tmp_path, monkeypatch):
|
||||
# A pure hit must write nothing: the manifest + image mtimes stay frozen, so
|
||||
# on-disk timestamps alone reveal whether the cache was hit.
|
||||
_patch_download(monkeypatch)
|
||||
p = NativeMarkdownParser()
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_extract(p, src, parsed)
|
||||
raw = _raw_dir(tmp_path)
|
||||
before = {c.name: c.stat().st_mtime_ns for c in raw.iterdir()}
|
||||
|
||||
_wipe_parsed(parsed)
|
||||
_forbid_download(monkeypatch) # pure hit: no download allowed
|
||||
_, warnings, _ = _extract(p, src, parsed)
|
||||
assert warnings.get("images_cache_hit") == 1
|
||||
|
||||
after = {c.name: c.stat().st_mtime_ns for c in raw.iterdir()}
|
||||
assert after == before # nothing rewritten
|
||||
|
||||
|
||||
def test_source_change_invalidates_cache(tmp_path, monkeypatch):
|
||||
counter = _patch_download(monkeypatch)
|
||||
p = NativeMarkdownParser()
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 1
|
||||
src.write_text("# Changed\n\n\n")
|
||||
_wipe_parsed(parsed)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 2 # re-downloaded
|
||||
|
||||
|
||||
def test_options_signature_change_invalidates_cache(tmp_path, monkeypatch):
|
||||
counter = _patch_download(monkeypatch)
|
||||
p = NativeMarkdownParser()
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 1
|
||||
# Changing a byte-affecting knob busts the bundle.
|
||||
monkeypatch.setenv("NATIVE_MD_IMAGE_MAX_BYTES", "12345")
|
||||
_wipe_parsed(parsed)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 2
|
||||
|
||||
|
||||
def test_force_reparse_discards_cache(tmp_path, monkeypatch):
|
||||
counter = _patch_download(monkeypatch)
|
||||
p = NativeMarkdownParser()
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 1
|
||||
monkeypatch.setenv("LIGHTRAG_FORCE_REPARSE_NATIVE", "true")
|
||||
_wipe_parsed(parsed)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 2
|
||||
|
||||
|
||||
def test_tampered_cache_file_falls_back_to_download(tmp_path, monkeypatch):
|
||||
counter = _patch_download(monkeypatch)
|
||||
p = NativeMarkdownParser()
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 1
|
||||
# Corrupt the cached image bytes so the sha256 no longer matches the manifest.
|
||||
raw = _raw_dir(tmp_path)
|
||||
img = next(c for c in raw.iterdir() if c.name != "_manifest.json")
|
||||
img.write_bytes(b"corrupted")
|
||||
_wipe_parsed(parsed)
|
||||
_extract(p, src, parsed)
|
||||
assert counter["n"] == 2 # integrity mismatch -> miss -> re-download
|
||||
|
||||
|
||||
def test_svg_cached_as_png_and_reused_without_rasterizing(tmp_path, monkeypatch):
|
||||
# _download returns the post-rasterization PNG, so a cache hit reuses PNG
|
||||
# bytes and never calls cairosvg.
|
||||
_patch_download(monkeypatch, payload=(_PNG_BYTES, "png"))
|
||||
p = NativeMarkdownParser()
|
||||
src, parsed = _make_doc(tmp_path, text=f"# H\n\n\n")
|
||||
_, _, meta = _extract(p, src, parsed)
|
||||
(asset,) = meta["md_assets"].values()
|
||||
assert asset["fmt"] == "png" and asset["data"].startswith(b"\x89PNG")
|
||||
|
||||
_wipe_parsed(parsed)
|
||||
_forbid_download(monkeypatch)
|
||||
|
||||
import cairosvg
|
||||
|
||||
def _boom(*a, **k): # pragma: no cover - must not run on a cache hit
|
||||
raise AssertionError("cairosvg must not run on a cache hit")
|
||||
|
||||
monkeypatch.setattr(cairosvg, "svg2png", _boom)
|
||||
_, warnings, meta = _extract(p, src, parsed)
|
||||
assert warnings.get("images_cache_hit") == 1
|
||||
|
||||
|
||||
def test_native_raw_is_sibling_and_survives_parsed_rmtree(tmp_path, monkeypatch):
|
||||
_patch_download(monkeypatch)
|
||||
src, parsed = _make_doc(tmp_path)
|
||||
_extract(NativeMarkdownParser(), src, parsed)
|
||||
raw = _raw_dir(tmp_path)
|
||||
assert raw.is_dir()
|
||||
assert raw.parent == parsed.parent # sibling of .parsed, not nested
|
||||
shutil.rmtree(parsed) # base parser wipes parsed_dir
|
||||
assert raw.is_dir() # cache survives
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Routing + registry wiring for the native markdown engine.
|
||||
|
||||
``parser_rules=""`` is passed explicitly so these assertions are independent of
|
||||
any ambient ``LIGHTRAG_PARSER`` in the dev environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from lightrag.parser import registry
|
||||
from lightrag.parser.native_dispatch import NativeParser
|
||||
from lightrag.parser.routing import resolve_file_parser_directives
|
||||
|
||||
|
||||
def _engine(path: str, rules: str = "") -> str:
|
||||
return resolve_file_parser_directives(path, parser_rules=rules)[0]
|
||||
|
||||
|
||||
def test_native_declares_markdown_suffixes():
|
||||
assert registry.suffix_capabilities("native") == frozenset(
|
||||
{"docx", "md", "textpack"}
|
||||
)
|
||||
|
||||
|
||||
def test_textpack_and_md_in_upload_allowlist():
|
||||
suffixes = registry.available_engine_suffixes()
|
||||
assert "textpack" in suffixes
|
||||
assert "md" in suffixes
|
||||
|
||||
|
||||
def test_get_parser_native_is_dispatcher():
|
||||
parser = registry.get_parser("native")
|
||||
assert type(parser).__name__ == "NativeParser"
|
||||
|
||||
|
||||
def test_textpack_defaults_to_native_without_hint():
|
||||
assert _engine("note.textpack") == "native"
|
||||
|
||||
|
||||
def test_md_defaults_to_legacy_opt_in_for_native():
|
||||
assert _engine("doc.md") == "legacy"
|
||||
assert _engine("doc.[native].md") == "native"
|
||||
assert _engine("doc.md", rules="md:native") == "native"
|
||||
|
||||
|
||||
def test_docx_default_unchanged():
|
||||
assert _engine("report.docx") == "legacy"
|
||||
|
||||
|
||||
def test_textpack_falls_back_to_native_when_rule_engine_cannot_handle_it():
|
||||
# legacy cannot parse .textpack; the per-suffix default still wins.
|
||||
assert _engine("note.textpack", rules="textpack:legacy") == "native"
|
||||
|
||||
|
||||
class _SentinelParser:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
async def parse(self, ctx):
|
||||
return self.name
|
||||
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
|
||||
|
||||
def _dispatch(file_path: str) -> str:
|
||||
dispatcher = NativeParser()
|
||||
dispatcher._docx = _SentinelParser("docx")
|
||||
dispatcher._markdown = _SentinelParser("markdown")
|
||||
return asyncio.run(dispatcher.parse(_Ctx(file_path)))
|
||||
|
||||
|
||||
def test_dispatcher_routes_by_suffix():
|
||||
assert _dispatch("a.md") == "markdown"
|
||||
assert _dispatch("a.textpack") == "markdown"
|
||||
assert _dispatch("a.[native-iet].md") == "markdown"
|
||||
assert _dispatch("a.docx") == "docx"
|
||||
assert _dispatch("report.[native].docx") == "docx"
|
||||
Reference in New Issue
Block a user